59 Commits

Author SHA1 Message Date
c1cac91419 engine-cuda: LAIR_MINBLOCKS launch-bounds knob, with the occupancy results
No default change. Records on the knob what forcing the register budget
measured on beast against the quanpool kernel (quantus/miner#27): 64
registers by any launch shape spills and loses 5%, 80 is neutral.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ue5ZZm1Hiv5zPnucykKKuF
2026-09-14 00:49:11 +03:00
17caeabc10 Merge pull request 'perf: whole-grid batches, early reject on element 0, precomputed nonce direction (+2.9% on beast)' (#26) from perf/scheduling into main
All checks were successful
ci / fmt (push) Successful in 19s
deploy / build (push) Successful in 1m32s
ci / clippy (push) Successful in 1m45s
ci / doc (push) Successful in 1m54s
deploy / deploy (1, benjy.hanzalova.internal, cuda, bob.hanzalova.internal) (push) Successful in 35s
deploy / deploy (2, beast.hanzalova.internal, cuda, bob.hanzalova.internal) (push) Successful in 33s
deploy / deploy (1, quadbrat.hanzalova.internal, cuda, bob.hanzalova.internal) (push) Successful in 47s
ci / test (push) Successful in 6m46s
2026-09-13 21:06:31 +00:00
2e6fce162e engine-cuda: batches of whole grids, early reject on element 0, nonce direction
All checks were successful
ci / fmt (pull_request) Successful in 19s
bench / build (pull_request) Successful in 1m15s
ci / clippy (pull_request) Successful in 1m45s
ci / doc (pull_request) Successful in 1m58s
bench / measure (pull_request) Successful in 2m51s
ci / test (pull_request) Successful in 6m46s
Three scheduling changes measured against the #23 kernel, three interleaved
rounds each, parity clean on every host:

- Batches are rounded up to whole grids, two per batch by default
  (MINER_CUDA_BATCH_ALIGN, MINER_CUDA_BATCH_WAVES). The CLI's 1M-nonce
  batch left a half-empty tail wave and paid the launch gap every 1M
  hashes. beast +1.6% for one grid, +0.4% more for two; benjy +0.5% for
  the second grid; quadbrat neutral.
- LAIR_EARLY_REJECT: the second permutation stops before its last linear
  layer, computes output 0 alone (the hash's top 64 bits) and rejects on
  it; only the rare survivors pay for the other eleven outputs. +0.6%.
- LAIR_NONCE_DIR: the host precomputes the state after the first linear
  layer for the batch's first nonce (first_layer_after_absorb, unit test
  against the matrix column), and the kernel derives each nonce's state
  as that plus a scalar times column 7 of the external matrix; nonces
  past a carry out of the low limb take the old path. +0.25% on beast,
  +0.6% on the 4090 and 3060.

Also tried and rejected: 512-thread blocks (-0.4%), a smaller grid via
MINER_CUDA_THREADS_PER_SM=16384 (-0.6%), a larger one (65536, within
noise of two waves), and the whole S-box as one PTX block, which
compiles to byte-identical SASS. The threads-per-block knob
(MINER_CUDA_THREADS_PER_BLOCK with LAIR_TPB) stays for experiments.

beast 2x5090: 2146 -> ~2206 MH/s (+2.8%); benjy 4090: 711 -> ~718;
quadbrat 3060: 140.2 -> 141.2.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ue5ZZm1Hiv5zPnucykKKuF
2026-09-13 23:54:17 +03:00
dc6c58ceb6 Merge pull request 'perf: fused-PTX field arithmetic, +54..66% on every card' (#23) from perf/fused-field-ptx into main
All checks were successful
ci / fmt (push) Successful in 19s
deploy / build (push) Successful in 1m33s
ci / clippy (push) Successful in 1m46s
deploy / deploy (1, benjy.hanzalova.internal, cuda, bob.hanzalova.internal) (push) Successful in 46s
deploy / deploy (2, beast.hanzalova.internal, cuda, bob.hanzalova.internal) (push) Successful in 46s
deploy / deploy (1, quadbrat.hanzalova.internal, cuda, bob.hanzalova.internal) (push) Successful in 47s
ci / doc (push) Successful in 1m58s
ci / test (push) Successful in 7m1s
2026-09-13 19:40:26 +00:00
3763996ae6 engine-cuda: fused-PTX field arithmetic, +54..66% on every card
All checks were successful
ci / fmt (pull_request) Successful in 20s
bench / build (pull_request) Successful in 1m16s
ci / clippy (pull_request) Successful in 1m44s
bench / measure (pull_request) Successful in 2m52s
ci / doc (pull_request) Successful in 2m1s
ci / test (pull_request) Successful in 6m59s
The closed-source qpow-cuda kernel runs 2x our hashrate on the same
silicon with the same 1472 multiplies per hash. Its PTX shows why: every
field operation is one self-contained inline-PTX block, the 128-bit
product is reduced with mad.lo.cc/madc.hi.cc (one IMAD.WIDE with a
carry-out) plus three ALU ops, the internal layer's row sum rides in the
mad.wide partial products for free, and accumulators are 32-bit limb
chains that ptxas turns into IADD/IADD.X with merged carries. Ours paid
a compare-and-select reduction (~14 ops), a 64-bit add-with-carry per
diagonal element, and five instructions per accumulator add on sm_120.

This ports those primitives behind LAIR_FUSED_MUL (default 1): gf_mul,
gf_sqr (three-product squaring), gf_mul_add with the addend folded into
the partial products, acc_add/acc_add2 as limb chains, and a four-op
acc_reduce. The round constant now joins the unreduced sum in the
internal layer.

LAIR_EXACT_REDUCE (default 0) keeps the reduction bit-exact for three
more ops per multiply. With 0 the final borrow is dropped, as in their
kernel: wrong only when the product's bits 64..95 are zero and its low
64 bits are below its top 32 bits, about 2^-64 per multiply (emulated
against a reference: 0/3M random mismatches, 2^48*2^48 fails as
predicted). Exactness measured at -15% on beast, so it is off.

Measured, three interleaved rounds, parity 40/40 on each host:
  beast 2x5090  1356 -> 2146 MH/s  (+58%)
  benjy 4090     460 ->  711 MH/s  (+54%)
  quadbrat 3060   84 ->  140 MH/s  (+66%)
Static sm_120 31,280 -> 20,888 instructions, sm_86/89 39,208 -> 22,576,
no spills. ncu: 22,913 instructions per nonce against their 22,233.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ue5ZZm1Hiv5zPnucykKKuF
2026-09-13 22:29:46 +03:00
78dee04ff6 Merge pull request 'perf: parameter-bank uniforms and per-arch carry path (+20.5% sm_120)' (#22) from perf/sm120-spills into main
All checks were successful
ci / fmt (push) Successful in 20s
ci / doc (push) Successful in 2m34s
ci / clippy (push) Successful in 2m39s
ci / test (push) Successful in 7m8s
deploy / build (push) Successful in 1m46s
deploy / deploy (1, benjy.hanzalova.internal, cuda, bob.hanzalova.internal) (push) Successful in 35s
deploy / deploy (2, beast.hanzalova.internal, cuda, bob.hanzalova.internal) (push) Successful in 35s
deploy / deploy (1, quadbrat.hanzalova.internal, cuda, bob.hanzalova.internal) (push) Successful in 35s
2026-09-13 17:20:53 +00:00
575c4d0abd engine-cuda: per-arch carry path, +20.5% on sm_120
All checks were successful
ci / fmt (pull_request) Successful in 19s
bench / build (pull_request) Successful in 1m31s
ci / clippy (pull_request) Successful in 2m21s
ci / doc (pull_request) Successful in 2m24s
bench / measure (pull_request) Successful in 4m15s
ci / test (pull_request) Successful in 7m51s
An `asm` block is opaque to nvcc's optimiser, so carry arithmetic written
as inline PTX blocks common-subexpression elimination and strength
reduction across neighbouring field operations. On Blackwell that costs a
quarter of the kernel. On Ada and Ampere the hand-written sequence still
wins, by a similar margin in the other direction, so the default is now
chosen per device pass.

Whole-kernel instructions (ptxas + cuobjdump, CUDA 13.0) and measured
hashrate, three interleaved rounds each:

                    sm_120                sm_86/89
  PTX_CARRY=1   37,432   1127.1 MH/s   37,027   benjy 461.6  quadbrat 85.5
  PTX_CARRY=0   27,837   1358.0 MH/s   41,196   benjy 401.5  quadbrat 76.3

sm_86/89 keep the exact configuration they had, so those cards are
unchanged by construction -- identical macro values, identical 37,027
instructions. Parity 60/60 against the CPU on beast.

LAIR_PTX_ACC is now 1 everywhere rather than following LAIR_PTX_CARRY. In
the Acc accumulators the add-with-carry pair is the whole operation, so
there is nothing around it for the optimiser to fold; on sm_120 with the
carry path off, Acc PTX on measured 1333.3 against 1285.8 with it off.

PR #17 measured the PTX carry path as a win on every card. That was true
of the kernel it was written for, which spilled 104 bytes per thread;
once the spills went the trade reversed on Blackwell only.

Found by profiling rather than reading: ncu puts us at 43,700 executed
instructions per nonce against the rival binary's 22,229 on the same
card, a 1.97x ratio that matches the 2.05x throughput gap, with 82% of
the excess on the ALU pipe rather than the multiply pipe. This lands
35,785. The remaining gap is still ALU: 26,586 against their 14,413,
while the multiply pipe is now close to parity at 8,405 against 7,030.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ytCkttecH9SNTUEw8VT9D
2026-09-13 19:35:32 +03:00
206aa0d876 engine-cuda: record why gf_sqr uses the general product
The three-multiply squaring is a real algebraic saving and a measured
loss. Implemented, verified bit-exact against __int128 over 40M random
values plus limb and power-of-two boundaries, then benchmarked on beast:
1072.9 vs 1137.7 MH/s over three interleaved rounds, 5.7% slower.

It trades 6.7% of the kernel's widening multiplies for 11% more
instructions -- 5 IMAD.WIDE in 56 instructions against the general
product's 6 in 48 -- and four formulations (condition-code carry, compare
carry, two mad.wide.u32 variants) all compiled to the same 5/56, so that
is the floor rather than a tuning failure.

Taken with the LAIR_INT_UNROLL result, the picture is consistent: this
kernel is bound by instruction issue and by the cards' power envelope,
not by multiply throughput or occupancy. Re-expressing the same work
cannot win; only removing work can. Left as a comment so the next person
does not spend the afternoon rediscovering it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ytCkttecH9SNTUEw8VT9D
2026-09-13 18:42:48 +03:00
7804d9d442 engine-cuda: launch uniforms in the parameter bank, no spills
The kernel copied midstate, target and the nonce base into per-thread
register arrays and held all 56 registers live across the whole nonce
loop, which is exactly what the permutation's own working set needed.
ptxas spilled 104 bytes per thread on sm_120 and 44 on sm_86/89; the SASS
carried 26 LDL + 26 STL inside the hot loop.

They are launch-uniform, so they now travel as a by-value MiningUniforms
parameter. Parameters live in constant memory -- broadcast and cached, no
register residency -- and arrive with the launch, so three memcpy_htod
calls per batch are gone with them. Only the low eight nonce limbs are
materialised now; on a hit the high half is written from the parameter
bank. 128 -> 80/80/106 registers on sm_86/89/120, zero spill everywhere.
Both sides assert the 224-byte layout, so a mismatch is a build error
rather than a silent mining bug.

Measured A/B on the fleet, same session, same silicon, parity verified
against the CPU (25/25 on quadbrat and benjy, 40/40 on beast):

  quadbrat  3060    83.45 -> 84.60 MH/s   +1.38%
  benjy     4090   457.87 -> 461.77 MH/s  +0.85%
  beast   2x5090  1133.97 -> 1138.79 MH/s +0.43%
  fleet           1675.29 -> 1685.16 MH/s +0.59%

Not taken: a per-arch LAIR_INT_UNROLL of 1 on sm_120. It is better by
every static measure -- 78 registers instead of 106, three resident
blocks per SM instead of two, no spill either way -- and measured 0.92%
slower on beast over three interleaved rounds. These cards are
power-bound, not latency-bound: the extra warps buy power draw and the
card clocks down to pay for it. Benjy shows the same thing from the other
side, delivering +0.85% at a lower clock (2085 vs 2160 MHz), +4.4% per
MHz. Occupancy is not the lever here; work per hash is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ytCkttecH9SNTUEw8VT9D
2026-09-13 18:32:20 +03:00
2cfb2aae1d Merge pull request 'deploy: miner credentials from the mainnet chain' (#21) from deploy/mainnet-chain into main
All checks were successful
deploy / build (push) Successful in 1m19s
ci / fmt (push) Successful in 20s
deploy / deploy (1, benjy.hanzalova.internal, cuda, bob.hanzalova.internal) (push) Successful in 38s
deploy / deploy (1, quadbrat.hanzalova.internal, cuda, bob.hanzalova.internal) (push) Successful in 42s
deploy / deploy (2, beast.hanzalova.internal, cuda, bob.hanzalova.internal) (push) Successful in 46s
ci / clippy (push) Successful in 2m8s
ci / doc (push) Successful in 1m55s
ci / test (push) Successful in 7m18s
2026-09-09 09:04:30 +00:00
b1c6eb2ba7 deploy: miner credentials from the mainnet chain
All checks were successful
ci / fmt (pull_request) Successful in 22s
ci / clippy (pull_request) Successful in 1m54s
ci / doc (pull_request) Successful in 2m6s
ci / test (pull_request) Successful in 7m39s
Mainnet cut over 2026-09-09 09:01 UTC; the miners already run with the
credentials from chains/mainnet/ (installed by hand). The push default
now matches, so the next deploy copies the same files.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBgs2nSi4H2mdh8kD8vMX5
2026-09-09 12:03:21 +03:00
fd965ea312 Merge pull request 'deploy: beast joins the miner fleet for mainnet' (#20) from deploy/enable-beast into main
All checks were successful
ci / fmt (push) Successful in 20s
deploy / build (push) Successful in 1m37s
deploy / deploy (1, benjy.hanzalova.internal, cuda, bob.hanzalova.internal) (push) Successful in 37s
ci / clippy (push) Successful in 2m25s
deploy / deploy (2, beast.hanzalova.internal, cuda, bob.hanzalova.internal) (push) Successful in 33s
deploy / deploy (1, quadbrat.hanzalova.internal, cuda, bob.hanzalova.internal) (push) Successful in 53s
ci / doc (push) Successful in 2m30s
ci / test (push) Successful in 7m32s
2026-09-09 06:15:02 +00:00
7ac9608b3d deploy: beast joins the miner fleet for mainnet
Some checks failed
ci / fmt (pull_request) Successful in 26s
ci / doc (pull_request) Successful in 2m25s
ci / test (pull_request) Successful in 7m33s
ci / clippy (pull_request) Failing after 12m11s
Mainnet call 2026-09-09: beast's two RTX 5090s mine alongside benjy and
quadbrat; neuron (cortex inference) is disabled on all three hosts. The
node's miners list and lair/quantus's SCRAPE_MINERS gain beast in the
lair/quantus counterpart.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBgs2nSi4H2mdh8kD8vMX5
2026-09-09 09:14:32 +03:00
03b9c4239f Merge pull request 'deploy: chain id as a dispatch input for the miner credential path' (#19) from deploy/chain-input into main
All checks were successful
ci / fmt (push) Successful in 39s
ci / doc (push) Successful in 2m5s
ci / clippy (push) Successful in 2m57s
deploy / build (push) Successful in 3m43s
deploy / deploy (1, quadbrat.hanzalova.internal, cuda, bob.hanzalova.internal) (push) Successful in 42s
deploy / deploy (1, benjy.hanzalova.internal, cuda, bob.hanzalova.internal) (push) Successful in 45s
ci / test (push) Successful in 8m50s
2026-09-09 05:05:36 +00:00
9f41e0f3b9 deploy: chain id as a dispatch input for the miner credential path
All checks were successful
ci / fmt (pull_request) Successful in 46s
ci / clippy (pull_request) Successful in 2m56s
ci / test (pull_request) Successful in 6m49s
ci / doc (pull_request) Successful in 2m18s
The node generates the miner auth token and TLS pin per chain under
<base-path>/chains/<id>/, so the mainnet cutover needs one miner deploy
pointed at the new id (lair/quantus's node deploy prints it). The binary
is unchanged by a chain switch; only the credentials trigger the restart.
CHAIN stays the push-time default and is to be bumped after the cutover.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBgs2nSi4H2mdh8kD8vMX5
2026-09-09 08:05:04 +03:00
f061ccfeea Merge pull request 'bench: record the memory clock alongside the SM clock (schema 2)' (#18) from bench/record-memory-clock into main
All checks were successful
ci / fmt (push) Successful in 28s
deploy / build (push) Successful in 1m23s
ci / clippy (push) Successful in 1m38s
deploy / deploy (1, quadbrat.hanzalova.internal, cuda, bob.hanzalova.internal) (push) Successful in 38s
ci / doc (push) Successful in 1m45s
deploy / deploy (1, benjy.hanzalova.internal, cuda, bob.hanzalova.internal) (push) Successful in 2m38s
ci / test (push) Successful in 6m38s
2026-09-03 15:54:47 +00:00
a7a8ff0532 bench: record the memory clock alongside the SM clock (schema 2)
All checks were successful
ci / fmt (pull_request) Successful in 36s
bench / build (pull_request) Successful in 1m37s
ci / doc (pull_request) Successful in 1m42s
ci / clippy (pull_request) Successful in 2m5s
bench / measure (pull_request) Successful in 3m48s
ci / test (pull_request) Successful in 7m3s
The memory clock is a confound like the power limit: locking it at 810 MHz
gave +13% on the 4090 at the same watts (lair/quantus#10). Records from
before and after that change must be distinguishable.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBgs2nSi4H2mdh8kD8vMX5
2026-09-03 18:47:17 +03:00
ad66f1334f Merge pull request 'engine-cuda: deferred carries, carry-flag arithmetic, one nonce per thread' (#17) from cuda/tuning into main
All checks were successful
ci / fmt (push) Successful in 21s
deploy / build (push) Successful in 1m15s
deploy / deploy (1, benjy.hanzalova.internal, cuda, bob.hanzalova.internal) (push) Successful in 56s
ci / clippy (push) Successful in 2m13s
deploy / deploy (1, quadbrat.hanzalova.internal, cuda, bob.hanzalova.internal) (push) Successful in 37s
ci / doc (push) Successful in 2m17s
ci / test (push) Successful in 6m53s
2026-09-03 13:01:46 +00:00
b640d3a711 engine-cuda: fold round constants into the preceding linear layer; bench on cuda paths
All checks were successful
ci / fmt (pull_request) Successful in 23s
bench / build (pull_request) Successful in 1m0s
ci / clippy (pull_request) Successful in 1m57s
bench / measure (pull_request) Successful in 2m51s
ci / doc (pull_request) Successful in 1m52s
ci / test (pull_request) Successful in 7m25s
Each linear layer now folds the constant of the round that follows it into
its final deferred-carry reduction (or, for the internal rounds, into the
element-0 multiply-add), removing the per-element gf_add before every S-box.
Parity verified. On benjy the effect is within run-to-run noise: three
interleaved rounds gave medians of 418.5 vs 409.7 MH/s against the previous
default, with SM clock swinging 1935-2025 MHz across rounds. Kept on as
LAIR_FOLD_RC=1 for the instruction count; not claimed as a measured gain.

bench.yaml: crates/engine-cuda/** now triggers the harness on PRs. The
previous PR's kernel changes did not run it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBgs2nSi4H2mdh8kD8vMX5
2026-09-03 15:56:23 +03:00
2bf3246634 engine-cuda: deferred carries, carry-flag arithmetic, one nonce per thread
All checks were successful
ci / fmt (pull_request) Successful in 21s
ci / clippy (pull_request) Successful in 1m43s
ci / doc (pull_request) Successful in 1m41s
ci / test (pull_request) Successful in 6m52s
quantus/miner#3 step 5, measured on benjy (RTX 4090, 250 W, miner paused,
interleaved rounds, parity verified on every variant):

  variant                                  MH/s
  straight port (PR #16)                   297-305
  + grid cap 32768 threads/SM              304   (1 nonce per thread up to 4M)
  + deferred-carry linear layers           344   (+13%)
  + carry-flag gf_add / gf_reduce (PTX)    385   (+12%)
  + carry-flag accumulators                414   (+7%)

414 MH/s is 2.86x origin's wgpu build (144.4) on the same card and limit.
Fewer instructions per hash also let the power-capped card clock higher
(1920 -> 1995 MHz), which is part of the gain.

Rejected by measurement: __noinline__ permute (-13%: code size was not the
limit, the call convention was), -maxrregcount 80/64 (flat), internal-round
unroll 1 or 22 (flat, within noise of 2).

The batch sweep found batch size irrelevant once the grid keeps one nonce
per thread (304-306 MH/s from 1M to 16M); batch stays at 1M for the
smallest stale cost. All knobs remain as -D macros and MINER_NVCC_FLAGS
so the harness can keep testing variants.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBgs2nSi4H2mdh8kD8vMX5
2026-09-03 15:51:11 +03:00
d5e72d6a15 Merge pull request 'engine-cuda: native CUDA mining engine behind MinerEngine' (#16) from cuda/engine-skeleton into main
All checks were successful
ci / fmt (push) Successful in 20s
deploy / build (push) Successful in 1m19s
ci / clippy (push) Successful in 1m54s
deploy / deploy (1, benjy.hanzalova.internal, cuda, bob.hanzalova.internal) (push) Successful in 58s
ci / doc (push) Successful in 1m57s
deploy / deploy (1, quadbrat.hanzalova.internal, cuda, bob.hanzalova.internal) (push) Successful in 58s
ci / test (push) Successful in 7m25s
2026-09-03 11:57:42 +00:00
6ecc5ee6c7 engine-cuda: native CUDA mining engine behind MinerEngine
All checks were successful
ci / fmt (pull_request) Successful in 20s
bench / build (pull_request) Successful in 1m15s
ci / clippy (pull_request) Successful in 1m40s
ci / doc (pull_request) Successful in 2m6s
bench / measure (pull_request) Successful in 2m51s
ci / test (pull_request) Successful in 7m45s
quantus/miner#3. New crate, nothing in engine-gpu touched.

- kernels/mining.cu: the same host contract and sponge schedule as
  mining_u64.wgsl (host midstate, low-256-bit nonce increments, lazy second
  squeeze), bit-exact with pow_core. The field multiply is __umul64hi plus a
  plain multiply and one reduction, instead of four 32-bit partials with
  carry reconstruction.
- build.rs: generates poseidon2_constants.cuh from qp-poseidon-constants
  (a hash change at origin is a dependency bump, never a hand-copied table)
  and, when nvcc is present, compiles one fat binary with cubins for
  sm_86/89/120 plus compute_120 PTX. Without nvcc it writes an empty image,
  the crate still builds, and CudaEngine::try_new fails with a clear message
  so the miner falls back to wgpu.
- lib.rs: CudaEngine with the same batch loop, cancellation, thread-local
  device assignment, metrics (kernel label "cuda") and stale accounting as
  engine-gpu. cudarc 0.19 with dynamic loading; no link-time CUDA dependency.
- miner-service: resolve_gpu_configuration tries CUDA first unless
  --gpu-engine wgpu; --gpu-engine cuda makes its absence an error.
  miner-cli gains the flag on serve and benchmark (MINER_GPU_ENGINE).
- bench-harness: --engine auto|cuda|wgpu.
- deploy.yaml: matrix rows carry the kernel validate expects; a wgpu
  fallback on a CUDA host now fails the deploy instead of passing at a
  fraction of the hashrate.

Measured on quadbrat (RTX 3060, 130 W, miner paused, 2 x 10 s windows,
parity 5/5 verified against CPU): wgpu 37.3 MH/s, CUDA 59.5 MH/s (1.59x).
Grid sizing 8192 threads/SM or more is flat; 2048 loses 10%.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBgs2nSi4H2mdh8kD8vMX5
2026-09-03 14:52:10 +03:00
48ab3933ba Merge pull request 'metrics: stale work is the batch in flight at cancellation, not the whole search' (#15) from metrics/stale-semantics into main
All checks were successful
ci / fmt (push) Successful in 20s
deploy / build (push) Successful in 1m5s
deploy / deploy (1, benjy.hanzalova.internal, bob.hanzalova.internal) (push) Successful in 1m10s
ci / clippy (push) Successful in 2m33s
ci / doc (push) Successful in 2m32s
deploy / deploy (1, quadbrat.hanzalova.internal, bob.hanzalova.internal) (push) Successful in 47s
ci / test (push) Successful in 7m25s
2026-09-03 11:28:43 +00:00
20749c622b metrics: stale work is the batch in flight at cancellation, not the whole search
All checks were successful
ci / fmt (pull_request) Successful in 21s
bench / build (pull_request) Successful in 1m1s
ci / clippy (pull_request) Successful in 1m55s
ci / doc (pull_request) Successful in 1m55s
bench / measure (pull_request) Successful in 2m56s
ci / test (pull_request) Successful in 6m35s
miner_stale_hashes_total was recorded in the QUIC loop for every result whose
job id no longer matched, which is every cancelled search: 74% of all hashes
on benjy flagged as stale on the first hour of data. That is the loop's
notion of a stale *result*, not wasted work; the hashes were done while the
job was current.

The wasted work is the batch that completes after the job was superseded.
Record that in the engine at the cancellation check, per device and kernel,
and drop the loop-level accounting. Expect one batch per job switch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBgs2nSi4H2mdh8kD8vMX5
2026-09-03 14:23:37 +03:00
1c8174951c Merge pull request 'metrics: attribute hashrate to build, device, kernel and job outcomes' (#14) from metrics/build-device-job into main
All checks were successful
ci / fmt (push) Successful in 32s
deploy / build (push) Successful in 1m14s
ci / clippy (push) Successful in 1m42s
deploy / deploy (1, benjy.hanzalova.internal, bob.hanzalova.internal) (push) Successful in 51s
deploy / deploy (1, quadbrat.hanzalova.internal, bob.hanzalova.internal) (push) Successful in 51s
ci / doc (push) Successful in 1m55s
ci / test (push) Successful in 6m56s
2026-09-03 11:16:25 +00:00
3ba996dcdf metrics: attribute hashrate to build, device, kernel and job outcomes
All checks were successful
ci / fmt (pull_request) Successful in 20s
bench / build (pull_request) Successful in 1m2s
ci / clippy (pull_request) Successful in 1m37s
ci / doc (pull_request) Successful in 1m58s
bench / measure (pull_request) Successful in 2m55s
ci / test (pull_request) Successful in 6m50s
quantus/miner#9. Additive metrics in a lair-owned module of the metrics
crate; origin's metrics are untouched so the fleet dashboard keeps working
across origin merges.

Identity:
- miner_build_info{version, commit}: the join key for everything below.
- miner_config_info{engine, gpu_batch_size, gpu_devices, cpu_workers,
  gpu_throttle_ms}: two deploys of one commit with different flags are
  different experiments.

Per device (handles resolved once at engine init, no label lookups in the
batch path):
- miner_device_hashes_total{device, kernel}, miner_device_solutions_total,
  miner_device_lost_total.
- miner_gpu_batch_seconds{device, kernel, phase=gpu|host}: submit-to-mapped
  on the device versus everything else in the batch. This is the bubble
  #4, #5 and #6 attack, measured directly.

Jobs and results:
- miner_jobs_received_total, miner_stale_hashes_total{engine},
  miner_job_pickup_seconds{engine} (issued to picked up; the cancel latency
  of a busy worker), miner_results_submitted_total,
  miner_results_send_failed_total, miner_seal_latency_seconds (found to
  sent), miner_job_idle_seconds_total (sent to next job, node-attributable).

Connection: miner_connects_total, miner_connect_failures_total,
miner_disconnects_total, miner_connected, miner_disconnected_seconds_total.

Origin-owned code touched, each block marked lair: engine-gpu gains a
metrics dependency, a DeviceMetrics handle on GpuContext and timing points
in run_single_batch; miner-service gains found_at on WorkerResult,
created_at on MiningJob and the recording calls; miner-cli sets build info
at startup. Deploy validate now asserts miner_build_info carries the
deployed commit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBgs2nSi4H2mdh8kD8vMX5
2026-09-03 14:09:43 +03:00
f1064345e5 Merge pull request 'deploy: wait for the miner to connect before judging its metrics' (#13) from deploy/validate-readiness into main
All checks were successful
ci / fmt (push) Successful in 44s
deploy / build (push) Successful in 1m9s
deploy / deploy (1, benjy.hanzalova.internal, bob.hanzalova.internal) (push) Successful in 1m31s
ci / clippy (push) Successful in 1m51s
deploy / deploy (1, quadbrat.hanzalova.internal, bob.hanzalova.internal) (push) Successful in 50s
ci / doc (push) Successful in 2m16s
ci / test (push) Successful in 7m7s
2026-09-03 10:38:21 +00:00
40b4ea32ca deploy: restore the previous binary with install, not cp
All checks were successful
ci / fmt (pull_request) Successful in 22s
ci / clippy (pull_request) Successful in 1m51s
ci / doc (pull_request) Successful in 2m10s
ci / test (pull_request) Successful in 6m56s
The first rollback failed with "cp: cannot create regular file
'/usr/local/bin/quantus-miner': Text file busy": cp writes in place and the
binary was executing. install unlinks the destination first, the same reason
rsync's temp-file-and-rename push works. Sudoers grant updated and applied
to benjy and quadbrat.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBgs2nSi4H2mdh8kD8vMX5
2026-09-03 13:38:09 +03:00
b10f2f0099 deploy: wait for the miner to connect before judging its metrics
All checks were successful
ci / fmt (pull_request) Successful in 20s
ci / clippy (pull_request) Successful in 1m49s
ci / doc (pull_request) Successful in 2m40s
ci / test (pull_request) Successful in 8m1s
The first deploy-mode run sampled /metrics one second after the restart.
miner_gpu_devices is exported only once the miner has connected to the node
and miner_hashes_total only once it has hashed, so both were absent, validate
failed, and rollback restored the previous binary on a good deploy.

Validate now waits up to 90 s for miner_gpu_devices to appear (readiness),
then takes the first non-empty miner_hashes_total sample as the baseline and
requires a larger one within 120 s.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBgs2nSi4H2mdh8kD8vMX5
2026-09-03 13:37:23 +03:00
95b6420793 Merge pull request 'deploy: build on push to main and land the binary on the mining hosts' (#12) from deploy/on-main into main
Some checks failed
ci / test (push) Blocked by required conditions
ci / fmt (push) Successful in 25s
deploy / build (push) Successful in 1m5s
ci / clippy (push) Successful in 1m50s
ci / doc (push) Successful in 1m49s
deploy / deploy (1, benjy.hanzalova.internal, bob.hanzalova.internal) (push) Failing after 2m37s
deploy / deploy (1, quadbrat.hanzalova.internal, bob.hanzalova.internal) (push) Failing after 2m30s
2026-09-03 10:33:17 +00:00
b9e1d21ec9 deploy: build on push to main and land the binary on the mining hosts
All checks were successful
ci / fmt (pull_request) Successful in 24s
ci / clippy (pull_request) Successful in 1m38s
ci / doc (pull_request) Successful in 2m0s
ci / test (pull_request) Successful in 7m47s
quantus/miner#8. The miner is now deployed from this repo, not from
lair/quantus's release-pinned workflow (lair/quantus#4 retires that half).

- .gitea/workflows/deploy.yaml: build on cuda-13.0 (Fedora 43 like the
  hosts, and the runner with nvcc for #3) with the commit embedded, then per
  host in the matrix: sudoers preflight against deploy/infra-setup.sh,
  credentials copied from the node, miner.env rendered with the node's mesh
  IP resolved on the miner host, checksum-gated push of binary and unit,
  scoped firewalld rule for the exporter, restart only on change. Validate
  asserts the unit is active, --version carries this commit, the miner sees
  the matrix's device count, and miner_hashes_total advances within 120 s.
  If validate fails after a restart, the previous binary is restored.
- deploy/: the unit, sysusers, firewalld service and env template moved
  verbatim from lair/quantus asset/, plus infra-setup.sh with the miner role
  (and two new cp grants for the rollback). Applied to benjy and quadbrat.
- Matrix: benjy and quadbrat; beast present but commented out per the
  operating policy in #1.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBgs2nSi4H2mdh8kD8vMX5
2026-09-03 13:28:52 +03:00
ad846af984 Merge pull request 'bench: quantus-bench harness and a workflow that measures on a mining host' (#11) from bench/harness into main
All checks were successful
ci / fmt (push) Successful in 20s
ci / clippy (push) Successful in 1m55s
ci / doc (push) Successful in 1m52s
ci / test (push) Successful in 6m21s
2026-09-03 10:19:33 +00:00
ca6642960c bench: serialise measurements per host with a flock, per-run file names
All checks were successful
ci / fmt (pull_request) Successful in 31s
bench / build (pull_request) Successful in 1m1s
ci / clippy (pull_request) Successful in 1m55s
bench / measure (pull_request) Successful in 2m59s
ci / test (pull_request) Successful in 9m7s
ci / doc (pull_request) Successful in 1m42s
Gitea's concurrency group did not serialise two runs on benjy: the second
run's scp failed with ETXTBSY on the binary the first was executing (safe,
it happened before the miner was touched, but the run was lost). The pause,
lock, measure and resume sequence now lives in bench-on-host.sh, executed
over one ssh call under flock on the host, with per-run binary and record
names so staging never collides.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBgs2nSi4H2mdh8kD8vMX5
2026-09-03 13:06:45 +03:00
5f3c51c5cb bench: allow more worker threads than cards to measure submission overlap
Some checks failed
ci / fmt (pull_request) Successful in 21s
bench / build (pull_request) Successful in 56s
bench / measure (pull_request) Failing after 8s
ci / clippy (pull_request) Successful in 1m49s
ci / doc (pull_request) Successful in 1m59s
ci / test (pull_request) Successful in 7m0s
Two threads on one card overlap one thread's readback with the other's
dispatch, which is the bubble quantus/miner#4 and #5 target. Measuring it in
the harness first means #4 lands with a number instead of a hypothesis.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBgs2nSi4H2mdh8kD8vMX5
2026-09-03 13:03:21 +03:00
36ffbbadbf Merge pull request 'ci: gitea workflow, drop inherited github workflows, embed commit in --version' (#10) from ci/gitea-workflows-and-build-sha into main
All checks were successful
ci / fmt (push) Successful in 51s
ci / clippy (push) Successful in 1m46s
ci / doc (push) Successful in 2m50s
ci / test (push) Successful in 10m57s
2026-09-03 10:02:39 +00:00
bad02fbc33 bench: quantus-bench harness and a workflow that measures on a mining host
All checks were successful
ci / fmt (pull_request) Successful in 20s
bench / build (pull_request) Successful in 57s
ci / doc (pull_request) Successful in 1m46s
ci / clippy (pull_request) Successful in 2m16s
bench / measure (pull_request) Successful in 2m49s
ci / test (pull_request) Successful in 10m33s
quantus/miner#2. The measurement gate every kernel and submission change
passes before merge.

crates/bench-harness (lair-owned, never touched by origin merges):
- drives GpuEngine through MinerEngine, one worker thread per card, with
  fixed timed windows after a warm-up; reports per-worker median MH/s and
  spread and refuses (exit 5) when spread exceeds 2%
- captures nvidia-smi state (driver, power limit, draw, clock, temperature)
  before and after, and refuses to run (exit 3) when the enforced power limit
  is not the expected one, since that is the largest confound on these cards
- GPU/CPU parity on random jobs, same shape as engine-gpu's gpu_cpu_parity
  example, exit 4 on any mismatch
- one JSON record per run (schema 1) plus a markdown summary on stdout
- build.rs embeds the commit like miner-cli's

.gitea/workflows/bench.yaml:
- runner containers on the GPU hosts have no device passthrough, so the
  binary is built on cuda-13.0 (Fedora 43, matching the hosts; the rust
  image is Fedora 44 and its binaries need a newer glibc) and executed on
  the host over ssh as gitea_ci
- stops quantus-miner.service for the window and starts it again under a
  trap; refuses to measure a card that is busy with the miner stopped
- benjy (4090, dedicated) by default; host, duration, runs, batch size and
  workers are dispatch inputs; PRs touching engine or service crates trigger
  it and get the summary as a comment
- one measurement per host at a time (concurrency group)

The stop/start sudoers grants were added to lair/quantus infra-setup.sh
(lair/quantus#5) and applied to benjy and quadbrat.

Origin coupling: one line in the workspace members list, marked.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBgs2nSi4H2mdh8kD8vMX5
2026-09-03 12:58:48 +03:00
db6316713a ci: add rustfmt and clippy components explicitly
All checks were successful
ci / fmt (pull_request) Successful in 20s
ci / clippy (pull_request) Successful in 2m4s
ci / doc (pull_request) Successful in 2m8s
ci / test (pull_request) Successful in 6m27s
rustup auto-installs the pinned toolchain from rust-toolchain on the runner
but not its listed components; the fmt job failed with "cargo-fmt is not
installed for the toolchain". Add the component in the two jobs that need it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBgs2nSi4H2mdh8kD8vMX5
2026-09-03 12:54:29 +03:00
094536a341 ci: gitea workflow, drop inherited github workflows, embed commit in --version
Some checks failed
ci / fmt (pull_request) Failing after 39s
ci / clippy (pull_request) Has been skipped
ci / test (pull_request) Has been skipped
ci / doc (pull_request) Has been skipped
First lair-only change on top of origin v4.0.2 (quantus/miner#8, #1).

- Delete .github/ (workflows and the disk action). Gitea Actions reads
  .github/workflows too, so origin's GitHub-hosted jobs would queue forever
  for runners that do not exist here. Standing resolution on every
  origin-main merge: `git rm -r .github/workflows`.
- Add .gitea/workflows/ci.yml on the `rust` runner: fmt, clippy with
  -D warnings (same invocation as clippy.sh), build, test, doc, and a check
  that the built binary reports a commit in --version.
- crates/miner-cli/build.rs embeds MINER_BUILD_SHA (env override, else
  git rev-parse with a -dirty marker, else "unknown") and --version prints
  "<semver> (<sha>)". A deploy that follows main needs to assert the running
  binary is the commit it shipped; the semver alone cannot do that.

Origin coupling: .gitea/ and build.rs are new files; main.rs gains one const
and one attribute argument behind a `// lair:` marker.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBgs2nSi4H2mdh8kD8vMX5
2026-09-03 12:50:45 +03:00
Nikolaus Heger
cb6deb9139 bump version to v4.0.2 (#93)
Some checks failed
CI / 🏁 Fast Checks (Format) (push) Has been cancelled
CI / 🛠️ Build & Test (push) Has been cancelled
CI / 🤖 Analysis (Clippy & Doc) (push) Has been cancelled
CI / 🏃 Benchmark (push) Has been cancelled
Co-authored-by: n13 <n13@users.noreply.github.com>
2026-09-01 23:25:24 +08:00
Nikolaus Heger
09323d33e4 Different kernels for Apple Metal vs Nvidia and everything else (#92)
* Select Apple Metal 4.0.1 kernel; keep 4.0.0 u64 elsewhere

NVIDIA was ~37% slower on the 4.0.1 Metal-tuned kernel. Split
Poseidon2 kernels and pick by backend:

- Metal + SHADER_INT64: v4.0.1 u64
- other SHADER_INT64: v4.0.0 u64
- no SHADER_INT64: 32-bit fallback (same as 4.0.0)

CI uploads the Linux release binary as an artifact so we can
bench NVIDIA without building on the GPU box.

* Drop version numbers from GPU kernel labels

Log native-u64 vs native-u64 Apple Metal without tying
the strings to release tags.
2026-09-01 23:21:43 +08:00
Nikolaus Heger
ceff470a5f ci: pin the stable rust toolchain (#91)
CI resolved the floating stable channel, so Rust 1.98 shipped the
chunks_exact_to_as_chunks clippy lint and failed checks that passed on
an older local stable. Pin the exact version, matching how chain pins
its toolchains.
2026-09-01 19:03:54 +08:00
Nikolaus Heger
41ec132139 bump version to v4.0.1 (#89)
Co-authored-by: n13 <n13@users.noreply.github.com>
2026-09-01 19:03:05 +08:00
Nikolaus Heger
ea5e5e742e Speed up the u64 mining kernel ~1.8x on Apple Metal (#87)
Apple M5 Pro, CLI benchmark: 24.0 -> 43.2 MH/s. Kernel output is
unchanged and bit-exact against the CPU reference.

Field arithmetic:
- Deferred-carry additions (Acc): the external layer and the internal
  row sum accumulate unreduced with a carry counter and fold once per
  output instead of two epsilon corrections per add.
- gf64_mul_add folds the row sum into the 128-bit product before a
  single plain reduction.
- mul_wide / gf64_sqr assemble the 128-bit product from 32-bit-valued
  partial sums with no carry compares.

Kernel structure (code size dominates on Apple's compiler; every
unrolled variant measured slower):
- One loop drives all 30 rounds so each layer is emitted once, with
  round constants added by the linear layer preceding each S-box
  (RC_EXT table, RC_INTERNAL padded with a trailing zero).
- One S-box site with a runtime lane count (12 external, 1 internal).
- mining_main runs pad / first squeeze / second squeeze through a
  single inlined permute64 call in a phase loop.

Measured and rejected: uniform buffers and unhoisted loads, workgroup
sizes 64/128/512, 8-add MDS, one-compare reduce, fully unrolled
layers, two nonces per thread.
2026-08-31 22:50:27 +08:00
I Dewa Gede Bisma Mahendra
696eb51125 feat: add dependency cooldown (#88) 2026-08-28 14:11:24 +08:00
Nikolaus Heger
8d0c9f8023 docs: update systemd example for the serve CLI with required node auth (#86)
* docs: update systemd example for the serve CLI with required node auth

The unit and readme still described the old HTTP-API miner (MINER_PORT,
MINER_ENGINE, no subcommand). Update ExecStart to 'serve' and document the
now-required MINER_AUTH_TOKEN_FILE / MINER_TLS_CERT_SHA256_FILE, including
how to copy the node's miner-auth-token and miner-tls-cert-sha256 files
past ProtectHome=true, and refresh the env var reference to the current
CLI.

* docs: fix env-file example and CPU-worker claims per review

- Move inline comments off the env assignments: systemd EnvironmentFile=
  keeps inline '# ...' text as part of the value, so the copied example fed
  Clap unparseable values and the unit crash-looped under Restart=always.
- Describe the real worker behavior: CPUs are counted via the process
  affinity mask (num_cpus); unset MINER_CPU_WORKERS auto-detects ~50%, and
  an explicit value is used as-is — there is no clamp or warning.
- Drop the 'logs the detected cpuset mask' claim from the unit and override
  comments; no such logging exists in the miner.

* docs: metrics exporter is always on; purge-chain does not rotate credentials

- The Prometheus exporter starts unconditionally and binds plaintext HTTP on
  0.0.0.0:9900 by default; MINER_METRICS_PORT only changes the port. Say so
  everywhere instead of 'when metrics are enabled', and add a hardening note
  to firewall the port.
- Rephrase the credential-rotation pitfall: purge-chain removes only the
  database, so the node reloads the same miner-auth-token and TLS cert;
  rotation only happens when the base path changes or the files themselves
  are deleted.
2026-08-14 11:46:35 +08:00
illuzen
37f7837f76 bump version to v4.0.0 (#85)
Co-authored-by: n13 <n13@users.noreply.github.com>
2026-08-13 19:34:16 +08:00
illuzen
2c8d530de4 Send miner auth token in Ready handshake. (#78)
* Send miner auth token in Ready handshake.

Require --auth-token or --auth-token-file when connecting to a node so the
miner matches the node's shared-secret QUIC auth.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Pin the node's miner TLS certificate by SHA-256 fingerprint.

Require --tls-cert-sha256 or --tls-cert-sha256-file so miners reject
MITM certs instead of accepting any self-signed server certificate.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fmt

* Docs: prefer reading miner auth token from file, not node logs.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Version the miner ALPN as quantus-miner/2 to match the node.

A node/miner protocol mismatch now fails cleanly at the TLS handshake with
"no application protocol" instead of an opaque auth error.

Co-authored-by: Cursor <cursoragent@cursor.com>

* point at git

* Depend on published quantus-miner-api 0.3.0 from crates.io.

The temporary git branch pin is no longer needed now that 0.3.0 is published.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fail closed on permanent auth/TLS misconfig instead of reconnect looping.

Validate the fingerprint and Ready frame size at miner/pool startup before
metrics, workers, or HTTP come up. Treat node auth rejection and pin mismatch
as PermanentConnectError (no retry), and only reset reconnect backoff after
the first NewJob proves auth succeeded.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Point miner auth docs and examples at miner-auth-token / TLS pin files.

The node never logs the auth token; CLI/pool help and every serve/Docker
example now require the chain config files (or env vars) instead of implying
credentials can be copied from logs.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fmt

* Remove the miner Dockerfile and Docker packaging docs/CI.

Container GPU access is a poor fit for this miner, and the Docker examples
were unusable with SocketAddr (hostnames rejected). Run the native binary
instead.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Shut down the pool on permanent upstream auth failure; keep reconnecting otherwise.

Supervise run_node_client alongside HTTP so a bad token/pin ends the process
instead of leaving a live API on a dead upstream. Node restarts still reconnect
with backoff; the current job is cleared while disconnected so captchas are not
issued against a stale header.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Replace obsolete EXTERNAL_MINER_PROTOCOL.md with a pointer to MINING.md.

The local copy still described the pre-auth protocol (no Ready token, no pin,
old ALPN, 16 MB frames). Canonical docs live in quantus-miner-api and the
node's MINING.md.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Delete EXTERNAL_MINER_PROTOCOL.md; README already points at MINING.md.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 09:50:03 +08:00
Nikolaus Heger
2b966e780a Revert "Fuzz GPU/CPU parity at scale and CPU-verify every seal before submission"
This reverts commit b0cfc3005e.
2026-08-12 15:41:38 +08:00
Nikolaus Heger
b0cfc3005e Fuzz GPU/CPU parity at scale and CPU-verify every seal before submission
Investigating reported seal submission failures. Root cause analysis:
the miner's job-id staleness chain (worker check, quic-loop filter,
node-side job_id compare) cannot mislabel a candidate — internal and
node job ids are updated atomically together. The remaining benign race
is node-side: a block-template rebuild (new pre_hash, same parent) does
not bump the node's job counter, so an in-flight seal mined on the old
template passes the job-id check and fails seal verification, logging
"Failed to submit seal". Fixing that belongs in the chain repo (bump the
job counter on template rebuild).

Miner-side hardening in this commit:

- gpu_cpu_parity is now a two-phase fuzzer. Phase 1 dumps FULL 512-bit
  hashes for randomized (header, nonce) pairs via the real
  midstate-resume datapath and verifies each against the canonical CPU
  implementation, multi-threaded (defaults to 1M hashes, ~5s; 10M runs
  in ~15s). Phase 2 fuzzes whole mining jobs with five profiles: random,
  carry-edge starts (saturated low limbs), 2^256-boundary crossings,
  impossible difficulty with exact hash-count assertion, and CPU-known
  solutions to catch false negatives. Seeded and reproducible; also
  asserts the submitted work bytes match the nonce.
  Verified clean: 10M bulk hashes + ~1,500 seals across 6 seeds.

- miner-service re-verifies every candidate on the CPU (one hash) before
  sending JobResult; an engine-produced invalid seal is never submitted
  and is logged loudly instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 14:24:05 +08:00
Nikolaus Heger
9b16df1dff Merge pull request #81 from Quantus-Network/gpu-poseidon2-optimization
GPU mining 4.1x faster on Apple Silicon: native-u64 Poseidon2, midstate precompute, lazy second squeeze
2026-08-12 12:10:58 +08:00
Nikolaus Heger
7e449c0a5f Benchmark: honor --gpu-batch-size/--cpu-batch-size and advance nonce ranges
The benchmark fed each worker a fixed range (10k CPU / 1M GPU nonces),
capping GPU dispatches at 1M regardless of --gpu-batch-size, and
re-searched the same range on every iteration. Worker ranges now step
through the nonce space and are sized from the batch flags, floored at
the old widths so tiny flags keep engine-internal cancellation batching
instead of turning per-call harness overhead into the measured quantity.
Batch-size flags are validated non-zero at parse time (serve and
benchmark) and echoed in the benchmark banner.
2026-08-12 12:04:44 +08:00
illuzen
22d87d4772 fix benches 2026-08-12 11:16:26 +08:00
Nikolaus Heger
8aa81ec853 Disable naga runtime shader checks for the trusted mining shaders (+8-9%)
Exploring a fully native Metal port of the mining kernel showed it runs
~16% faster than the wgpu path at equal batch size. Decomposing that gap:
~9% is naga's injected runtime bounds checks and forced loop bounding,
~1.5% is mulhi()-based multiplication (inexpressible in WGSL), <1% is
the constant address space; the rest is binding/codegen residue.

The dominant term is recoverable inside wgpu on every backend:
the engine now builds its pipelines with create_shader_module_trusted +
ShaderRuntimeChecks::unchecked(). This is sound for our shaders: sources
are compiled into the binary, every buffer access is a constant-bounded
loop index into fixed-size bindings the engine allocates itself, all
loops have static bounds, and both variants remain covered by the
dual-shader component suite and the CPU parity example.

Apple M4: 11.39 -> 12.34 MH/s at the default 1M batch (+8.3%), 11.74 ->
12.73 MH/s at 8M (+8.4%). Cumulative vs main: 4.4x.

New examples/trusted_hashrate.rs measures checked vs trusted on the same
shader and CPU-verifies results.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 00:55:15 +08:00
Nikolaus Heger
58615ed3fa Adopt PR #80 micro-optimizations that measure positive on the u64 kernel
Evaluated all ideas from #80 against the native-u64 kernel. Its three
main wins (lazy field arithmetic, precomputed sponge state, conditional
second squeeze) were already present in stronger form; two micro-opts
carried over and measured positive at large batches (+1.3% at 16M,
noise-level at the 1M default):

- Hoist midstate/target/start-nonce storage reads out of the nonce loop.
- Produce byte-swapped hash words on demand during the target compare,
  so the reject path skips building the swapped hash entirely.

Forcing nonces_per_thread=2 was also tested and regressed; one thread
per nonce stays.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 00:01:39 +08:00
Nikolaus Heger
d55b970ab6 Optimize GPU Poseidon2 mining 4.1x on Apple Silicon (u64 shader, midstate, lazy squeeze)
Three stacked optimizations, each verified bit-exact against the CPU
reference (qp-poseidon-core) by the dual-shader component suite and a
new GPU/CPU parity example:

- mining_u64.wgsl: native 64-bit Goldilocks arithmetic with plonky2-style
  lazy reduction, used automatically when the adapter supports
  SHADER_INT64 (all Apple Silicon, NVIDIA, modern AMD). mining.wgsl
  remains the 32-bit fallback. (+50%)
- Midstate precompute: the first two of five sponge permutations absorb
  the header and high nonce half, both constant per batch; they are now
  computed once on the CPU (pow_core::mining_midstate) and resumed on
  the GPU. Batches are clamped so nonces never carry past 2^256. (+77%)
- Lazy second squeeze: the first squeeze yields the most significant 256
  bits of the hash, which decide hash-vs-target unless exactly equal to
  the target's high half, so the common reject path skips the final
  permutation. (+56%)

Apple M4 throughput: 2.81 -> 11.4 MH/s at the default 1M batch
(criterion large_range_1m/gpu: 355.8ms -> 86.3ms, -75.7%).

Also:
- Fix criterion GPU bench crash: thread-local worker resources are now
  tagged with an engine id so multiple GpuEngines per process never mix
  devices.
- Fix stale end-to-end test harness (6-binding layout) and run the full
  component suite against both shader variants.
- New examples: hashrate, gpu_cpu_parity (25 jobs incl. 2^256-boundary
  crossing), gpu_features.
- Add Apple M5 family GPU tiers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 23:40:12 +08:00
Nikolaus Heger
0135e65278 Merge pull request #76 from Quantus-Network/add-security-policy
Add security policy
2026-08-11 22:08:43 +08:00
Nikolaus Heger
2eb0341a70 chore: bump qp-poseidon-core to 3.1.0 (#77)
Update workspace pin from 2.1.0 to 3.1.0 for sponge wipe hardening.
(qpow-math from the pinned chain tag may still pull 2.1.0 transitively.)
2026-08-08 15:47:42 +08:00
Nikolaus Heger
0c61e3da34 Add security policy
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 12:11:38 +08:00
illuzen
71781a0a80 Add captcha share pool service and browser WASM solver (#74)
* Add captcha share pool service and browser WASM solver

pool-service connects to a node over the external-miner QUIC protocol and
turns browser captcha solves into real mining shares: sessions with disjoint
nonce ranges over the current header, single-use share tokens, a
reCAPTCHA-shaped /siteverify endpoint, and upstream block submission when a
share meets full network difficulty. solver-wasm is a raw C-ABI wasm32 build
of the Poseidon2 nonce grinder (no wasm-bindgen) for the embeddable widget.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fmt

* Re-queue block solution when upstream write fails

A network-difficulty share taken off the solution channel was permanently
lost if the JobResult write to the node failed: the connection loop broke to
reconnect but the block was no longer anywhere. Park the block in a pending
slot that survives reconnection and is retried before any other work.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fmt

* Address PR review: capacity limits, loud block drops, shared QUIC transport, constant-time secret compare

- Cap live sessions and tokens (configurable via --max-sessions/--max-tokens,
  default 100k each); /api/session returns 503 at_capacity so a request flood
  can no longer grow the maps without bound.
- A block solution that fails to queue upstream is now logged at error level,
  and blocks_found/block_found only report solutions actually queued.
- Extract the node QUIC connect + insecure-verifier pattern into a shared
  quic-transport crate used by both miner-service and pool-service; derive
  the network target via pow_core::JobContext instead of re-deriving it.
- Compare the site secret in constant time (constant_time_eq).
- Note single-tenant token semantics on site_secret for the host-registration
  follow-up.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add per-IP rate limiting on /api/session issuance

The global session cap stops unbounded map growth, but a single client could
still churn entries at the TTL boundary. Limit each IP to 60 issuances per
minute (configurable via --sessions-per-ip-per-min); excess requests get 429
rate_limited. Stale IP windows are GC'd alongside sessions/tokens.

Co-authored-by: Cursor <cursoragent@cursor.com>

* clippy

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 17:52:38 +08:00
70 changed files with 8172 additions and 1429 deletions

View File

@@ -1,42 +0,0 @@
# Git
.git
.gitignore
.github
# Build artifacts
target/
*.rs.bk
*.pdb
# IDE
.vscode
.idea
*.swp
*.swo
*~
# Documentation
docs/
*.md
!README.md
# Examples and tests (not needed for runtime)
examples/
# Other
.DS_Store
Thumbs.db
# CI/CD
.github/
# License
LICENSE
# Dockerfiles themselves
Dockerfile*
.dockerignore
# Cargo cache
.cargo/

143
.gitea/workflows/bench.yaml Normal file
View File

@@ -0,0 +1,143 @@
---
# GPU hashrate and parity measurement on a fleet mining host (quantus/miner#2).
#
# Runner containers on the GPU hosts have no device passthrough (gongfoo gives
# them `devices: None`), so the benchmark cannot run inside the runner. The
# binary is built on a runner and executed on the host over ssh as gitea_ci,
# which has the GPU device nodes (0666) and the Vulkan ICD, exactly like the
# miner. The host's quantus-miner.service is stopped for the window and started
# again afterwards, under a trap, so a failed run cannot leave the host idle.
#
# Benjy (4090, dedicated) is the reference card; beast (2x 5090) also serves
# inference and is only benchmarked by manual dispatch.
name: bench
on:
workflow_dispatch:
inputs:
host:
description: mining host to measure
default: benjy.hanzalova.internal
type: choice
options:
- benjy.hanzalova.internal
- quadbrat.hanzalova.internal
- beast.hanzalova.internal
duration_secs:
description: seconds per timed window
default: "30"
runs:
description: timed windows (median reported)
default: "5"
batch_size:
description: nonces per GPU batch
default: "1000000"
workers:
description: worker threads (one per card)
default: "1"
pull_request:
paths:
- crates/engine-gpu/**
- crates/engine-cuda/**
- crates/engine-cpu/**
- crates/pow-core/**
- crates/miner-service/**
- crates/bench-harness/**
- Cargo.lock
- .gitea/workflows/bench.yaml
env:
CARGO_TERM_COLOR: always
BENCH_HOST: ${{ github.event.inputs.host || 'benjy.hanzalova.internal' }}
DURATION: ${{ github.event.inputs.duration_secs || '30' }}
RUNS: ${{ github.event.inputs.runs || '5' }}
BATCH: ${{ github.event.inputs.batch_size || '1000000' }}
WORKERS: ${{ github.event.inputs.workers || '1' }}
# One measurement per host at a time; a second one would share the card.
concurrency:
group: bench-${{ github.event.inputs.host || 'benjy.hanzalova.internal' }}
cancel-in-progress: false
jobs:
build:
# cuda-13.0, not rust: the rust image is Fedora 44 and the mining hosts are
# Fedora 43, so a binary built there needs a glibc the hosts do not have
# (observed: "GLIBC_2.43 not found"). cuda-13.0 is Fedora 43 based, matches
# the hosts, and is what the deploy in #8 builds on anyway.
runs-on: cuda-13.0
steps:
- uses: actions/checkout@v4
- name: build quantus-bench
env:
MINER_BUILD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
run: cargo build --release --locked -p bench-harness
- uses: actions/upload-artifact@v3
with:
name: quantus-bench
path: target/release/quantus-bench
measure:
runs-on: fedora-43
needs: build
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v3
with: { name: quantus-bench, path: _bin }
- name: write ssh key
run: |
set -euo pipefail
install -d -m 0700 ~/.ssh
printf '%s\n' "${{ secrets.RSYNC_SSH_KEY }}" > ~/.ssh/id_gitea_ci
chmod 0600 ~/.ssh/id_gitea_ci
- name: measure on ${{ env.BENCH_HOST }}
run: |
set -euo pipefail
SSHOPTS="-i $HOME/.ssh/id_gitea_ci -o StrictHostKeyChecking=accept-new"
run() { ssh $SSHOPTS gitea_ci@"$BENCH_HOST" "$@"; }
# Per-run file names: two runs may overlap on the host until the
# host-side flock serialises them, and scp over a running binary
# fails with ETXTBSY.
id="${{ github.run_id }}"
dir=/var/lib/gitea_ci/bench
run "install -d -m 0750 $dir"
scp $SSHOPTS -q _bin/quantus-bench gitea_ci@"$BENCH_HOST":$dir/quantus-bench.$id
scp $SSHOPTS -q crates/bench-harness/bench-on-host.sh gitea_ci@"$BENCH_HOST":$dir/bench-on-host.$id.sh
run "chmod 0755 $dir/quantus-bench.$id"
cleanup() { ssh $SSHOPTS gitea_ci@"$BENCH_HOST" "rm -f $dir/quantus-bench.$id $dir/bench-on-host.$id.sh $dir/record.$id.json"; }
trap cleanup EXIT
label="${{ github.event.pull_request.number && format('pr-{0}', github.event.pull_request.number) || github.ref_name }}"
# The host script pauses the miner, holds the per-host lock, measures,
# and resumes the miner under its own trap.
run "bash $dir/bench-on-host.$id.sh $dir/quantus-bench.$id $DURATION $RUNS $BATCH $WORKERS $label $dir/record.$id.json" | tee bench.log
# The summary is everything from the harness's markdown header on.
sed -n '/^## quantus-bench/,$p' bench.log > bench.md
scp $SSHOPTS -q gitea_ci@"$BENCH_HOST":$dir/record.$id.json record.json
{ echo; cat bench.md; } >> "$GITHUB_STEP_SUMMARY"
- uses: actions/upload-artifact@v3
if: always()
with:
name: bench-record-${{ env.BENCH_HOST }}
path: |
record.json
bench.md
- name: comment on the pull request
if: github.event_name == 'pull_request'
run: |
set -euo pipefail
body=$(python3 - <<'PY'
import json, pathlib
print(json.dumps({"body": pathlib.Path("bench.md").read_text()}))
PY
)
curl -fsS -X POST \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Content-Type: application/json" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${{ github.event.pull_request.number }}/comments" \
-d "$body" > /dev/null

81
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,81 @@
---
# Lint, build and test on the fleet's `rust` runner (architecture/gitea-runners.md).
#
# Replaces origin's .github/workflows/ci.yml, which targeted GitHub-hosted
# runners that do not exist here. Gitea Actions also reads .github/workflows,
# so origin's files are deleted rather than left inert; on every origin merge
# `git rm -r .github/workflows` is the standing resolution (quantus/miner#1).
#
# Deliberately not here: origin's CPU benchmark job (meaningless on a shared
# 4-CPU runner; the real gate is the GPU harness, quantus/miner#2) and taplo
# (not on the runner image; add it to runner-rust per gitea-runners.md §5
# rather than `cargo install` on every run).
name: ci
on:
pull_request:
push:
branches: [main, origin-main]
concurrency:
group: ci-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
CARGO_INCREMENTAL: "0"
CARGO_TERM_COLOR: always
# rust-toolchain pins 1.93.0. rustup on the runner auto-installs the toolchain
# on first use but NOT the components the file lists (observed: "cargo-fmt is
# not installed for the toolchain"), so each job that needs one adds it
# explicitly; the call is a no-op once present.
jobs:
fmt:
runs-on: rust
steps:
- uses: actions/checkout@v4
- name: toolchain
run: rustup component add rustfmt
- name: rustfmt
run: cargo fmt --all -- --check
clippy:
runs-on: rust
needs: fmt
steps:
- uses: actions/checkout@v4
- name: toolchain
run: rustup component add clippy
- name: clippy (all targets, all features, warnings denied)
# Same invocation as clippy.sh so local and CI agree.
run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
test:
runs-on: rust
needs: fmt
steps:
- uses: actions/checkout@v4
- name: build
run: cargo build --locked --workspace
- name: test
# GPU tests skip themselves when no adapter is present; the runner has none.
run: cargo test --locked --workspace
- name: version carries the commit
# build.rs embeds the SHA; a binary that says "unknown" cannot be
# matched to a deploy (quantus/miner#8).
run: |
set -euo pipefail
v=$(./target/debug/quantus-miner --version)
echo "$v"
case "$v" in
*unknown*) echo "build SHA not embedded" >&2; exit 1 ;;
esac
doc:
runs-on: rust
needs: fmt
steps:
- uses: actions/checkout@v4
- name: doc
run: cargo doc --locked --workspace --no-deps --all-features

View File

@@ -0,0 +1,382 @@
---
# Deploy — or validate — the miner on the fleet's mining hosts (quantus/miner#8).
#
# Every push to main builds the binary and lands it on each host in the matrix,
# validated, with rollback. This workflow is the source of infra truth for the
# miner: hosts and per-host settings live in the `deploy` matrix and nowhere
# else. The node it attaches to is deployed by quantus/chain; the fleet's
# monitoring, GPU power limits and nvidia metrics stay in lair/quantus.
#
# Build on cuda-13.0: it is Fedora 43 like the hosts (the `rust` image is
# Fedora 44 and its binaries fail on the hosts with GLIBC_2.43 not found), and
# it is the only runner with nvcc for the CUDA engine (#3). Deploy on
# fedora-43: ssh + rsync are on every runner (gitea-runners.md §3).
name: deploy
on:
push:
branches: [main]
paths-ignore:
- "**.md"
- .gitea/workflows/ci.yml
- .gitea/workflows/bench.yaml
workflow_dispatch:
inputs:
mode:
description: "deploy (apply) or validate (check only, no changes)"
required: false
default: deploy
type: choice
options: [deploy, validate]
chain:
description: "chain id under the node's chains/ directory to take miner credentials from (overrides CHAIN); the node deploy's validate step prints it"
required: false
# Never half-apply two deploys at once. (Not relied on for correctness of a
# single host: the restart is checksum-gated and the binary is rsynced atomically.)
concurrency:
group: deploy-miner
cancel-in-progress: false
env:
# The chain spec: the miner credential path on the node host derives from it.
# Must agree with lair/quantus's node deploy. Mainnet since 2026-09-09; the
# node writes the credentials under chains/mainnet/.
CHAIN: mainnet
MINER_LINK_PORT: "9833"
MINER_METRICS_PORT: "9900"
# Fleet Prometheus host; the miner's exporter is unauthenticated and bound to
# 0.0.0.0, so this is the only host allowed to reach it.
METRICS_HOST: golgafrinchans.kosherinata.internal
CARGO_TERM_COLOR: always
jobs:
build:
runs-on: cuda-13.0
steps:
- uses: actions/checkout@v4
- name: build quantus-miner
env:
# Embedded by crates/miner-cli/build.rs into --version; validate below
# asserts the host runs exactly this commit.
MINER_BUILD_SHA: ${{ github.sha }}
run: |
set -euo pipefail
cargo build --release --locked -p miner-cli
./target/release/quantus-miner --version
- uses: actions/upload-artifact@v3
with:
name: quantus-miner
path: target/release/quantus-miner
deploy:
runs-on: fedora-43
needs: build
strategy:
fail-fast: false # one host's failure must not abort the other
matrix:
include:
# `node` is the host whose QUIC control channel this miner attaches
# to, and whose reward address therefore receives what it earns. The
# node's deploy (quantus/chain) must list this host in its `miners`
# so the firewalld rich rule admits it. lair/quantus's SCRAPE_MINERS
# must list it so Prometheus scrapes it.
#
# Mainnet call 2026-09-09 (quantus/miner#1): all three hosts mine;
# neuron (cortex inference) is disabled on each of them.
# `kernel` is the kernel id validate expects on the per-device metric:
# cuda (engine-cuda, #3) on every NVIDIA host once the build carries
# the fat binary; u64 would mean the miner silently fell back to wgpu.
- host: benjy.hanzalova.internal
node: bob.hanzalova.internal
gpu_devices: "1" # 1x RTX 4090 (sm_89); reference card for #2
kernel: cuda
- host: quadbrat.hanzalova.internal
node: bob.hanzalova.internal
gpu_devices: "1" # 1x RTX 3060 (sm_86)
kernel: cuda
- host: beast.hanzalova.internal
node: bob.hanzalova.internal
gpu_devices: "2" # 2x RTX 5090 (sm_120)
kernel: cuda
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v3
with: { name: quantus-miner, path: _bin }
- name: write ssh key
run: |
set -euo pipefail
install -d -m 0700 ~/.ssh
printf '%s\n' "${{ secrets.RSYNC_SSH_KEY }}" > ~/.ssh/id_gitea_ci
chmod 0600 ~/.ssh/id_gitea_ci
- name: reachability
run: |
set -euo pipefail
ssh -i ~/.ssh/id_gitea_ci -o StrictHostKeyChecking=accept-new \
gitea_ci@${{ matrix.host }} hostname -f
- name: preflight — sudoers covers this deploy
run: |
set -euo pipefail
SSHOPTS="-i $HOME/.ssh/id_gitea_ci -o StrictHostKeyChecking=accept-new"
# Every path deploy/infra-setup.sh grants must already be permitted on
# the host. Compare up front, from the script itself so the two cannot
# drift, and name the missing paths instead of failing forty lines into
# an rsync with "sudo: a password is required".
sed -n "/quantus-miner_gitea_ci.tmp/,/^SUDO$/p" deploy/infra-setup.sh \
| grep '^gitea_ci ALL=' | grep -oE '(/etc|/usr|/var)/[^ ]*' | sort -u > expected-paths.txt
ssh $SSHOPTS gitea_ci@${{ matrix.host }} 'sudo -n -l' \
| grep -oE '(/etc|/usr|/var)/[^ ]*' | sort -u > permitted-paths.txt
comm -23 expected-paths.txt permitted-paths.txt > missing-paths.txt
if [ -s missing-paths.txt ]; then
echo "the miner sudoers on ${{ matrix.host }} is out of date." >&2
echo "not permitted, but this deploy needs them:" >&2
sed 's/^/ /' missing-paths.txt >&2
echo "" >&2
echo "run: ./deploy/infra-setup.sh --pubkey ~/.ssh/id_gitea_ci.pub" >&2
exit 1
fi
echo "sudoers covers all $(wc -l < expected-paths.txt) paths this deploy needs"
- name: deploy miner
id: deploy
if: ${{ github.event.inputs.mode != 'validate' }}
env:
GPU_DEVICES: ${{ matrix.gpu_devices }}
run: |
set -euo pipefail
SSHOPTS="-i $HOME/.ssh/id_gitea_ci -o StrictHostKeyChecking=accept-new"
nrun() { ssh $SSHOPTS gitea_ci@"${{ matrix.node }}" "$@"; }
run() { ssh $SSHOPTS gitea_ci@"${{ matrix.host }}" "$@"; }
# -c (checksum), not rsync's default size+mtime quick check: the
# artifact is freshly built every run so mtimes always differ, and the
# default heuristic would report a change on every deploy. Restart is
# gated on an itemised content difference only.
RESTART=0
push() {
local out
out=$(rsync -e "ssh $SSHOPTS" --rsync-path='sudo rsync' -ic "$@")
if [ -n "$out" ]; then
RESTART=1
printf '%s\n' "$out" | sed 's/^/ changed: /'
fi
}
# 1. service account + dirs
push --mkpath --chmod=F0644 \
deploy/quantus-miner.sysusers.conf \
gitea_ci@"${{ matrix.host }}":/etc/sysusers.d/quantus-miner.conf
run sudo systemd-sysusers
run sudo install -d -o root -g quantus-miner -m 0750 /etc/quantus-miner
run sudo install -d -o quantus-miner -g quantus-miner -m 0750 /var/lib/quantus-miner
# 2. The miner's credentials are GENERATED BY THE NODE on first start
# and regenerate if the node's base-path is ever wiped. Copying them
# on every deploy is what makes that self-healing instead of a
# silent auth failure. They pass through the runner in memory,
# never the workspace.
umask 077
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
nrun sudo cat /var/lib/quantus-node/chains/${{ github.event.inputs.chain || env.CHAIN }}/miner-auth-token \
> "$tmp/miner-auth-token"
nrun sudo cat /var/lib/quantus-node/chains/${{ github.event.inputs.chain || env.CHAIN }}/miner-tls-cert-sha256 \
> "$tmp/miner-tls-cert-sha256"
test -s "$tmp/miner-auth-token" || { echo "node auth token empty — has ${{ matrix.node }} started?" >&2; exit 1; }
test -s "$tmp/miner-tls-cert-sha256" || { echo "node TLS pin empty — has ${{ matrix.node }} started?" >&2; exit 1; }
push --chown=root:quantus-miner --chmod=F0640 \
"$tmp/miner-auth-token" gitea_ci@"${{ matrix.host }}":/etc/quantus-miner/miner-auth-token
push --chown=root:quantus-miner --chmod=F0640 \
"$tmp/miner-tls-cert-sha256" gitea_ci@"${{ matrix.host }}":/etc/quantus-miner/miner-tls-cert-sha256
# 3. non-secret runtime config.
# --node-addr parses as a Rust SocketAddr: an IP and a port, with NO
# DNS resolution. Resolve on the MINER host — it is the one
# dialling — and keep the 10.x literal out of the repo.
node_addrs=$(run "getent ahostsv4 ${{ matrix.node }}")
node_ip=$(awk '{print $1; exit}' <<<"$node_addrs")
case "$node_ip" in
10.*) echo "node address: ${{ matrix.node }} -> ${node_ip}" ;;
*) echo "refusing to point the miner at non-mesh address '${node_ip}'" >&2; exit 1 ;;
esac
export NODE_ADDR="${node_ip}:${{ env.MINER_LINK_PORT }}"
python3 - <<'PY'
import os, pathlib
t = pathlib.Path("deploy/miner.env.tmpl").read_text()
t = t.replace("{{QUANTUS_NODE_ADDR}}", os.environ["NODE_ADDR"])
t = t.replace("{{QUANTUS_GPU_DEVICES}}", os.environ["GPU_DEVICES"])
pathlib.Path("miner.env").write_text(t)
PY
push --chown=root:quantus-miner --chmod=F0640 \
miner.env gitea_ci@"${{ matrix.host }}":/etc/quantus-miner/miner.env
# 4. binary + unit. Keep the running binary as .prev first so a failed
# validate can put it back (rollback step below).
if run test -x /usr/local/bin/quantus-miner; then
run sudo cp -p /usr/local/bin/quantus-miner /usr/local/bin/quantus-miner.prev
echo "previous binary kept: $(run /usr/local/bin/quantus-miner.prev --version)"
fi
push --chmod=F0755 _bin/quantus-miner gitea_ci@"${{ matrix.host }}":/usr/local/bin/quantus-miner
push --chmod=F0644 deploy/quantus-miner.service \
gitea_ci@"${{ matrix.host }}":/etc/systemd/system/quantus-miner.service
run sudo restorecon -R /usr/local/bin/quantus-miner /etc/quantus-miner /var/lib/quantus-miner
# 5. firewalld for the exporter, scoped to the scrape host. The miner
# binds metrics on 0.0.0.0 unconditionally.
rsync -e "ssh $SSHOPTS" --rsync-path='sudo rsync' -ic --mkpath --chmod=F0644 \
deploy/quantus-miner-metrics.xml \
gitea_ci@"${{ matrix.host }}":/etc/firewalld/services/quantus-miner-metrics.xml \
| sed 's/^/ changed: /'
run sudo firewall-cmd --reload
zone=$(run sudo firewall-cmd --get-default-zone)
metrics_addrs=$(run "getent ahostsv4 ${{ env.METRICS_HOST }}")
metrics_ip=$(awk '{print $1; exit}' <<<"$metrics_addrs")
case "$metrics_ip" in
10.*) echo "scrape source: ${metrics_ip}" ;;
*) echo "refusing to expose metrics to non-mesh address '${metrics_ip}'" >&2; exit 1 ;;
esac
mrich="rule family=ipv4 source address=${metrics_ip}/32 service name=quantus-miner-metrics accept"
if run "sudo firewall-cmd --zone=$zone --query-rich-rule='$mrich'"; then
echo "firewalld: metrics rich rule already present in ${zone}"
else
run "sudo firewall-cmd --permanent --zone=$zone --add-rich-rule='$mrich'"
run "sudo firewall-cmd --zone=$zone --add-rich-rule='$mrich'"
fi
run sudo systemctl enable quantus-miner.service # idempotent
if [ "$RESTART" = 1 ]; then
echo "changes applied — restarting"
run sudo systemctl daemon-reload
run sudo systemctl restart quantus-miner.service
elif run systemctl is-active --quiet quantus-miner.service; then
echo "nothing changed and the miner is running — left alone"
else
echo "nothing changed but the miner is down — starting it"
run sudo systemctl restart quantus-miner.service
fi
echo "restarted=$RESTART" >> "$GITHUB_OUTPUT"
- name: validate miner
id: validate
run: |
set -euo pipefail
SSHOPTS="-i $HOME/.ssh/id_gitea_ci -o StrictHostKeyChecking=accept-new"
run() { ssh $SSHOPTS gitea_ci@"${{ matrix.host }}" "$@"; }
fail=0
echo "--- unit (${{ matrix.host }} -> ${{ matrix.node }}) ---"
run systemctl is-active quantus-miner.service
echo "--- version ---"
got=$(run /usr/local/bin/quantus-miner --version)
echo "installed: ${got}"
case "$got" in
*"${{ github.sha }}"*) echo "binary is this commit" ;;
*)
if [ "${{ github.event.inputs.mode }}" = validate ]; then
echo "note: installed binary is not this commit (validate mode; not a failure)"
else
echo "installed binary does NOT carry commit ${{ github.sha }}" >&2; fail=1
fi ;;
esac
echo "--- gpu ---"
# An `active` miner that found no adapter still looks healthy to
# systemd; assert the GPU is enumerated and that the miner sees the
# number of devices the matrix says it should.
run "nvidia-smi --query-gpu=name,power.draw,utilization.gpu --format=csv,noheader"
echo "--- hashing ---"
# The counter is the only honest evidence this process is doing work
# rather than idling on a failed connection. After a restart the miner
# exports miner_gpu_devices only once it has connected to the node and
# miner_hashes_total only once it has hashed, so first wait for the
# gauge to appear (readiness), then require the counter to advance.
# Here-strings, not pipes: a pipe whose reader exits early SIGPIPEs the
# writer and pipefail turns that into exit 141.
scrape() { ssh $SSHOPTS gitea_ci@"${{ matrix.host }}" "curl -fsS http://127.0.0.1:${{ env.MINER_METRICS_PORT }}/metrics" || true; }
deadline=$((SECONDS + 90))
devs=""
while [ $SECONDS -lt $deadline ]; do
m=$(scrape)
devs=$(awk '/^miner_gpu_devices /{print $2; exit}' <<<"$m")
[ -n "$devs" ] && break
sleep 5
done
if [ -z "$devs" ]; then
echo " miner did not connect to ${{ matrix.node }} within 90s (no miner_gpu_devices exported)" >&2; fail=1
elif [ "${devs%.*}" != "${{ matrix.gpu_devices }}" ]; then
echo " miner_gpu_devices is ${devs}, matrix says ${{ matrix.gpu_devices }}" >&2; fail=1
else
echo " ok connected; miner_gpu_devices ${devs%.*}"
fi
# The build-info gauge is what ties every other metric to a commit
# (quantus/miner#9). In deploy mode it must carry this commit.
if grep -q "^miner_build_info{.*commit=\"${{ github.sha }}\"" <<<"$m"; then
echo " ok miner_build_info carries ${{ github.sha }}"
elif [ "${{ github.event.inputs.mode }}" != validate ]; then
echo " miner_build_info does not carry commit ${{ github.sha }}" >&2; fail=1
fi
# The kernel actually running. A wgpu fallback on a host that should
# run CUDA passes every other check at a fraction of the hashrate.
if grep -q "^miner_device_hashes_total{.*kernel=\"${{ matrix.kernel }}\"" <<<"$m"; then
echo " ok kernel ${{ matrix.kernel }} on device 0"
else
echo " expected kernel ${{ matrix.kernel }}, exported series:" >&2
grep "^miner_device_hashes_total" <<<"$m" >&2 || echo " (none yet)" >&2
if [ "${{ github.event.inputs.mode }}" != validate ]; then fail=1; fi
fi
deadline=$((SECONDS + 120))
h1=""; h2=""; ok=0
while [ $SECONDS -lt $deadline ]; do
m=$(scrape)
h=$(awk '/^miner_hashes_total /{print $2; exit}' <<<"$m")
if [ -n "$h" ]; then
if [ -z "$h1" ]; then
h1="$h"
elif [ "${h%.*}" -gt "${h1%.*}" ]; then
h2="$h"; ok=1; break
fi
fi
sleep 10
done
if [ "$ok" = 1 ]; then
echo " ok miner_hashes_total ${h1%.*} -> ${h2%.*}"
else
echo " miner_hashes_total did not advance within 120s (${h1:-none} -> ${h2:-none})" >&2
fail=1
fi
exit $fail
- name: rollback
# Only when this run replaced the binary and validate then failed.
if: ${{ failure() && steps.deploy.outputs.restarted == '1' }}
run: |
set -euo pipefail
SSHOPTS="-i $HOME/.ssh/id_gitea_ci -o StrictHostKeyChecking=accept-new"
run() { ssh $SSHOPTS gitea_ci@"${{ matrix.host }}" "$@"; }
if run test -x /usr/local/bin/quantus-miner.prev; then
echo "validate failed after a restart — restoring the previous binary"
# install, not cp: cp writes in place and fails with "Text file busy"
# on a running binary; install unlinks the destination first (the
# same reason rsync's temp-file-and-rename works for the push).
run sudo install -m 0755 /usr/local/bin/quantus-miner.prev /usr/local/bin/quantus-miner
run sudo restorecon -R /usr/local/bin/quantus-miner /etc/quantus-miner /var/lib/quantus-miner
run sudo systemctl restart quantus-miner.service
echo "restored: $(run /usr/local/bin/quantus-miner --version)"
else
echo "no previous binary to restore" >&2
fi
- name: journal
if: always()
run: |
ssh -i ~/.ssh/id_gitea_ci -o StrictHostKeyChecking=accept-new \
gitea_ci@${{ matrix.host }} journalctl -u quantus-miner.service -n 60 --no-pager

View File

@@ -1,15 +0,0 @@
---
name: free disk space
description: when rust compiling, free up some disk space
runs:
using: composite
steps:
- name: free disk space
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # 1.3.1
with:
android: true
dotnet: false
haskell: false
large-packages: false
swap-storage: false

View File

@@ -1,91 +0,0 @@
---
name: CI
on:
pull_request:
paths-ignore:
- "docs/**"
- "*.md"
- "LICENSE"
push:
branches:
- main
paths-ignore:
- "docs/**"
- "*.md"
- "LICENSE"
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
CARGO_INCREMENTAL: 0
CARGO_TERM_COLOR: always
jobs:
fast-checks:
name: 🏁 Fast Checks (Format)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: setup rust
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
components: rustfmt
- name: install taplo
run: cargo install taplo-cli --locked
- name: Run format checks
run: |
taplo format --check --config taplo.toml
cargo fmt --all -- --check
timeout-minutes: 5
build-and-test:
name: 🛠️ Build & Test
needs: fast-checks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: setup rust
uses: actions-rust-lang/setup-rust-toolchain@v1
- name: compile (gpu)
run: |
cargo build --locked --workspace
timeout-minutes: 90
- name: test (gpu)
run: |
cargo test --locked --workspace
timeout-minutes: 15
analysis:
name: 🤖 Analysis (Clippy & Doc)
needs: fast-checks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: setup rust
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
components: clippy
- name: clippy (all features)
run: cargo clippy --locked --workspace --all-features
timeout-minutes: 30
- name: doc
run: cargo doc --locked --workspace --no-deps --all-features
timeout-minutes: 15
benchmark:
name: 🏃 Benchmark
needs: fast-checks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: setup rust
uses: actions-rust-lang/setup-rust-toolchain@v1
- name: build benchmark binary
run: cargo build -p miner-cli --release
timeout-minutes: 60
- name: run cpu benchmark
run: ./target/release/quantus-miner benchmark --cpu-workers 2 --duration 5
timeout-minutes: 10

View File

@@ -1,139 +0,0 @@
name: Docker Image
on:
workflow_dispatch:
inputs:
specific_version:
description: "Optional: Specify a full version (e.g., v0.3.0 or v0.3.1-beta.1) to build. If empty, uses the latest release tag. MUST start with 'v'."
required: false
default: ""
use_main_dockerfile:
description: "Optional: Use the main Dockerfile instead of the tag version-specific Dockerfile. If empty, uses the tag version-specific Dockerfile."
required: false
default: false
type: boolean
env:
CARGO_TERM_COLOR: always
jobs:
determine_version:
runs-on: ubuntu-latest
outputs:
version_with_v: ${{ steps.version_info.outputs.version_with_v }}
is_valid_version: ${{ steps.version_info.outputs.is_valid_version }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get version information
id: version_info
run: |
set -ex
TARGET_VERSION=""
SPECIFIC_VERSION="${{ github.event.inputs.specific_version }}"
if [[ -n "$SPECIFIC_VERSION" ]]; then
if [[ ! "$SPECIFIC_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]; then
echo "::error::Specified version '$SPECIFIC_VERSION' is not a valid format. It must start with 'v' (e.g., v0.3.0 or v0.3.1-beta.1)."
echo "is_valid_version=false" >> $GITHUB_OUTPUT
exit 1
fi
TARGET_VERSION="$SPECIFIC_VERSION"
echo "Using specified version: $TARGET_VERSION"
else
echo "No specific version provided, determining latest release tag..."
TARGET_VERSION=$(git tag --list 'v*' --sort=-v:refname | head -n 1)
if [[ -z "$TARGET_VERSION" ]]; then
echo "::error::No version tags starting with 'v' (e.g., vX.Y.Z) found in the repository."
echo "is_valid_version=false" >> $GITHUB_OUTPUT
exit 1
fi
echo "Latest release version found: $TARGET_VERSION"
fi
echo "version_with_v=$TARGET_VERSION" >> $GITHUB_OUTPUT
echo "is_valid_version=true" >> $GITHUB_OUTPUT
build_and_publish_image:
needs: determine_version
if: needs.determine_version.outputs.is_valid_version == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
env:
GHCR_IMAGE_PATH: ghcr.io/quantus-network/quantus-miner
TARGET_VERSION_WITH_V: ${{ needs.determine_version.outputs.version_with_v }}
steps:
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Check if Docker image already exists and fail if so
id: check_image
run: |
set -ex
IMAGE_TO_CHECK="${{ env.GHCR_IMAGE_PATH }}:${{ env.TARGET_VERSION_WITH_V }}"
echo "Checking for image: $IMAGE_TO_CHECK"
if docker manifest inspect "$IMAGE_TO_CHECK" > /dev/null 2>&1; then
echo "::error::Image $IMAGE_TO_CHECK already exists. Aborting."
exit 1
else
echo "Image $IMAGE_TO_CHECK does not exist. Proceeding with build."
fi
- name: Checkout code at tag version
uses: actions/checkout@v4
with:
ref: ${{ env.TARGET_VERSION_WITH_V }}
- name: Checkout main branch for Dockerfile (if needed)
if: github.event.inputs.use_main_dockerfile == 'true'
uses: actions/checkout@v4
with:
ref: main
sparse-checkout: |
Dockerfile
.dockerignore
sparse-checkout-cone-mode: false
path: dockerfile-source
- name: Copy Dockerfile from main (if needed)
if: github.event.inputs.use_main_dockerfile == 'true'
run: |
cp dockerfile-source/Dockerfile ./
[ -f dockerfile-source/.dockerignore ] && cp dockerfile-source/.dockerignore ./ || true
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: true
platforms: linux/amd64,linux/arm64
provenance: false
tags: |
${{ env.GHCR_IMAGE_PATH }}:${{ env.TARGET_VERSION_WITH_V }}
${{ env.GHCR_IMAGE_PATH }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Print completion message
run: |
echo "Successfully built and pushed ${{ env.GHCR_IMAGE_PATH }}:${{ env.TARGET_VERSION_WITH_V }}"
echo "Image is also tagged as latest: ${{ env.GHCR_IMAGE_PATH }}:latest"

View File

@@ -1,199 +0,0 @@
---
name: Release Proposal
env:
CARGO_TERM_COLOR: always
on:
workflow_dispatch:
inputs:
target_branch:
description: "Target branch for the PR (default: main)"
required: false
type: string
default: "main"
version_type:
description: "Type of version bump"
required: true
default: "patch"
type: choice
options:
- patch
- minor
- major
- custom
custom_version:
description: 'Custom version string (e.g., 1.2.3). Only used if version_type is "custom".'
required: false
is_draft:
description: "Is this a draft release?"
required: true
type: boolean
default: false
jobs:
calculate-next-version:
name: 🧮 Calculate Next Version
runs-on: ubuntu-latest
outputs:
new_version: ${{ steps.versioner.outputs.new_version }}
new_tag: ${{ steps.versioner.outputs.new_tag }}
commit_sha_short: ${{ steps.vars.outputs.commit_sha_short }}
source_branch: ${{ steps.vars.outputs.source_branch }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
- name: Get current branch and commit SHA
id: vars
run: |
echo "commit_sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
echo "source_branch=$(git rev-parse --abbrev-ref HEAD)" >> $GITHUB_OUTPUT
- name: Get latest tag
id: latest_tag
run: |
# Get all version tags and sort them by version
latest_semver_tag=$(git tag -l "v[0-9]*.[0-9]*.[0-9]*" | sort -V | tail -n 1)
# If no tags found, use default
if [ -z "$latest_semver_tag" ]; then
latest_semver_tag="v0.0.0"
fi
echo "latest_tag_found=$latest_semver_tag" >> $GITHUB_OUTPUT
echo "Latest semantic version tag found: $latest_semver_tag"
- name: Calculate new version
id: versioner
env:
LATEST_TAG: ${{ steps.latest_tag.outputs.latest_tag_found }}
VERSION_TYPE: ${{ github.event.inputs.version_type }}
CUSTOM_VERSION: ${{ github.event.inputs.custom_version }}
run: |
# Remove 'v' prefix and any suffix for processing
current_version=${LATEST_TAG#v}
# Remove any suffix after the version number
current_version=$(echo "$current_version" | sed -E 's/-[^-]+$//')
if [[ "$VERSION_TYPE" == "custom" ]]; then
if [[ -z "$CUSTOM_VERSION" ]]; then
echo "Error: Custom version is selected but no custom_version string provided."
exit 1
fi
if [[ ! "$CUSTOM_VERSION" =~ ^v ]]; then
echo "Error: Custom version string MUST start with 'v' (e.g., v1.2.3)."
exit 1
fi
new_version="$CUSTOM_VERSION"
else
# Split version and pre-release part
IFS='-' read -r version_core prerelease_part <<< "$current_version"
IFS='.' read -r major minor patch <<< "$version_core"
# Increment based on type
if [[ "$VERSION_TYPE" == "major" ]]; then
major=$((major + 1))
minor=0
patch=0
elif [[ "$VERSION_TYPE" == "minor" ]]; then
minor=$((minor + 1))
patch=0
elif [[ "$VERSION_TYPE" == "patch" ]]; then
patch=$((patch + 1))
else
echo "Error: Invalid version_type: $VERSION_TYPE"
exit 1
fi
new_version="v$major.$minor.$patch"
fi
echo "New version: $new_version"
echo "new_version=$new_version" >> $GITHUB_OUTPUT
echo "new_tag=$new_version" >> $GITHUB_OUTPUT
update-cargo-toml:
name: 📝 Update version files
needs: calculate-next-version
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup rust
uses: actions-rust-lang/setup-rust-toolchain@v1
- name: Create version bump branch and PR
env:
NEW_VERSION: ${{ needs.calculate-next-version.outputs.new_version }}
NEW_TAG: ${{ needs.calculate-next-version.outputs.new_tag }}
GITHUB_TOKEN: ${{ secrets.ADMIN_PAT }}
SOURCE_BRANCH: ${{ needs.calculate-next-version.outputs.source_branch }}
TARGET_BRANCH: ${{ github.event.inputs.target_branch }}
run: |
set -ex
new_cargo_version=${NEW_VERSION#v}
branch_name="release/${NEW_VERSION}"
# Create new branch from source branch
git checkout "$SOURCE_BRANCH"
git checkout -b "$branch_name"
# Update version in workspace Cargo.toml (safer than cargo set-version)
echo "Updating workspace Cargo.toml to version: $new_cargo_version"
sed -i -E "s/^version\s*=\s*\"[0-9a-zA-Z.-]+\"/version = \"$new_cargo_version\"/" Cargo.toml
# Regenerate Cargo.lock with precise updates for our packages only
cargo update -p miner-cli --precise "$new_cargo_version"
cargo update -p miner-service --precise "$new_cargo_version"
cargo update -p pow-core --precise "$new_cargo_version"
cargo update -p metrics --precise "$new_cargo_version"
cargo update -p miner-telemetry --precise "$new_cargo_version"
cargo update -p engine-cpu --precise "$new_cargo_version"
cargo update -p engine-gpu --precise "$new_cargo_version"
# Verify everything compiles correctly
cargo check --workspace
# Commit changes
git config user.name "${{ github.actor }}"
git config user.email "${{ github.actor }}@users.noreply.github.com"
git add Cargo.toml Cargo.lock
git commit -m "bump version to $NEW_VERSION"
git push origin "$branch_name"
# Prepare PR title and body
PR_TITLE="Release $NEW_VERSION"
PR_BODY="Automated version bump for release $NEW_VERSION.
## Overview
- Version bump: ${{ github.event.inputs.version_type }}
- Type: ${{ github.event.inputs.version_type }}
- Draft: ${{ github.event.inputs.is_draft }}
## What changed
- Updated version in Cargo.toml and Cargo.lock
Triggered by workflow run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
# Prepare labels
PR_LABELS="release-proposal"
if [[ "${{ github.event.inputs.is_draft }}" == "true" ]]; then
PR_LABELS="$PR_LABELS,draft-release"
fi
gh pr create \
--title "$PR_TITLE" \
--body "$PR_BODY" \
--base "$TARGET_BRANCH" \
--head "$branch_name" \
--label "$PR_LABELS"

View File

@@ -1,157 +0,0 @@
---
name: Release Publish
on:
pull_request:
types: [closed]
branches:
- main
permissions:
contents: write
packages: write
env:
CARGO_TERM_COLOR: always
jobs:
create-tag:
name: Create Tag
if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release-proposal')
runs-on: ubuntu-latest
outputs:
version: ${{ steps.extract_version.outputs.version }}
tag: ${{ steps.extract_version.outputs.tag }}
is_draft: ${{ steps.extract_version.outputs.is_draft }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Extract version from PR title
id: extract_version
run: |
# Extract version from PR title (format: "Release vX.Y.Z")
VERSION_TAG=$(echo "${{ github.event.pull_request.title }}" | grep -o 'v[0-9]\+\.[0-9]\+\.[0-9]\+')
if [ -z "$VERSION_TAG" ]; then
echo "Error: Could not extract version from PR title: ${{ github.event.pull_request.title }}"
exit 1
fi
VERSION=${VERSION_TAG#v}
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "tag=$VERSION_TAG" >> $GITHUB_OUTPUT
# Check if this is a draft release
if [[ "${{ contains(github.event.pull_request.labels.*.name, 'draft-release') }}" == "true" ]]; then
echo "is_draft=true" >> $GITHUB_OUTPUT
else
echo "is_draft=false" >> $GITHUB_OUTPUT
fi
echo "Extracted version: $VERSION"
echo "Extracted tag: $VERSION_TAG"
- name: Create and push tag
run: |
git config user.name "${{ github.actor }}"
git config user.email "${{ github.actor }}@users.noreply.github.com"
git tag -a "${{ steps.extract_version.outputs.tag }}" -m "Release ${{ steps.extract_version.outputs.tag }}"
git push origin "${{ steps.extract_version.outputs.tag }}"
build:
needs: create-tag
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
# Linux builds
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
binary_name: quantus-miner
asset_name: quantus-miner-linux-x86_64
# Windows builds
- os: windows-latest
target: x86_64-pc-windows-msvc
binary_name: quantus-miner.exe
asset_name: quantus-miner-windows-x86_64.exe
# macOS builds (Intel)
- os: macos-15-intel
target: x86_64-apple-darwin
binary_name: quantus-miner
asset_name: quantus-miner-macos-x86_64
# macOS builds (Apple Silicon)
- os: macos-latest
target: aarch64-apple-darwin
binary_name: quantus-miner
asset_name: quantus-miner-macos-aarch64
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.create-tag.outputs.tag }}
- name: setup rust
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
target: ${{ matrix.target }}
- name: build binary
shell: bash
run: |
if [[ -n "${{ matrix.features }}" ]]; then
cargo build --release --locked --target ${{ matrix.target }} --features ${{ matrix.features }}
else
cargo build --release --locked --target ${{ matrix.target }}
fi
- name: prepare binary
shell: bash
run: |
cd target/${{ matrix.target }}/release
if [[ "${{ matrix.os }}" == "windows-latest" ]]; then
cp ${{ matrix.binary_name }} ${{ matrix.asset_name }}
else
cp ${{ matrix.binary_name }} ${{ matrix.asset_name }}
strip ${{ matrix.asset_name }}
fi
- name: upload binary artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.asset_name }}
path: target/${{ matrix.target }}/release/${{ matrix.asset_name }}
release:
needs: [create-tag, build]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.create-tag.outputs.tag }}
- name: download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: create release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.create-tag.outputs.tag }}
IS_DRAFT: ${{ needs.create-tag.outputs.is_draft }}
run: |
# Prepare draft flag
if [[ "$IS_DRAFT" == "true" ]]; then
DRAFT_FLAG="--draft"
else
DRAFT_FLAG=""
fi
# Create release with all artifacts
gh release create "$TAG" \
--title "Release $TAG" \
--generate-notes \
$DRAFT_FLAG \
artifacts/*/quantus-miner-*

150
Cargo.lock generated
View File

@@ -107,7 +107,7 @@ version = "0.38.0+1.3.281"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f"
dependencies = [
"libloading",
"libloading 0.8.9",
]
[[package]]
@@ -122,6 +122,23 @@ version = "0.21.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
[[package]]
name = "bench-harness"
version = "4.0.2"
dependencies = [
"clap",
"engine-cpu",
"engine-cuda",
"engine-gpu",
"env_logger",
"log",
"pow-core",
"primitive-types 0.13.1",
"rand 0.9.2",
"serde",
"serde_json",
]
[[package]]
name = "bit-set"
version = "0.8.0"
@@ -377,6 +394,12 @@ dependencies = [
"unicode-xid",
]
[[package]]
name = "constant_time_eq"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -518,6 +541,15 @@ dependencies = [
"typenum",
]
[[package]]
name = "cudarc"
version = "0.19.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "804764d10e844da09765a7b2ca9641a0851523d1702efb0d7299d73e31b86e80"
dependencies = [
"libloading 0.9.0",
]
[[package]]
name = "data-encoding"
version = "2.10.0"
@@ -571,7 +603,7 @@ dependencies = [
[[package]]
name = "engine-cpu"
version = "3.3.1"
version = "4.0.2"
dependencies = [
"criterion",
"hex",
@@ -580,9 +612,22 @@ dependencies = [
"rand 0.9.2",
]
[[package]]
name = "engine-cuda"
version = "4.0.2"
dependencies = [
"cudarc",
"engine-cpu",
"log",
"metrics",
"pow-core",
"primitive-types 0.13.1",
"qp-poseidon-constants",
]
[[package]]
name = "engine-gpu"
version = "3.3.1"
version = "4.0.2"
dependencies = [
"bytemuck",
"criterion",
@@ -591,12 +636,13 @@ dependencies = [
"futures",
"hex",
"log",
"metrics",
"pow-core",
"primitive-types 0.13.1",
"qp-plonky2",
"qp-plonky2-field",
"qp-poseidon-constants",
"qp-poseidon-core",
"qp-poseidon-core 3.1.0",
"rand 0.9.2",
"rand_chacha 0.9.0",
"regex",
@@ -1367,7 +1413,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76"
dependencies = [
"libc",
"libloading",
"libloading 0.8.9",
"pkg-config",
]
@@ -1405,6 +1451,16 @@ dependencies = [
"windows-link",
]
[[package]]
name = "libloading"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60"
dependencies = [
"cfg-if",
"windows-link",
]
[[package]]
name = "libm"
version = "0.2.16"
@@ -1470,7 +1526,7 @@ dependencies = [
[[package]]
name = "metrics"
version = "3.3.1"
version = "4.0.2"
dependencies = [
"anyhow",
"log",
@@ -1498,7 +1554,7 @@ dependencies = [
[[package]]
name = "miner-cli"
version = "3.3.1"
version = "4.0.2"
dependencies = [
"clap",
"engine-cpu",
@@ -1509,17 +1565,19 @@ dependencies = [
"miner-service",
"num_cpus",
"primitive-types 0.13.1",
"quic-transport",
"rand 0.9.2",
"tokio",
]
[[package]]
name = "miner-service"
version = "3.3.1"
version = "4.0.2"
dependencies = [
"anyhow",
"crossbeam-channel",
"engine-cpu",
"engine-cuda",
"engine-gpu",
"getrandom 0.2.17",
"hex",
@@ -1529,14 +1587,14 @@ dependencies = [
"pow-core",
"primitive-types 0.13.1",
"quantus-miner-api",
"quic-transport",
"quinn",
"rustls 0.21.12",
"tokio",
]
[[package]]
name = "miner-telemetry"
version = "3.3.1"
version = "4.0.2"
dependencies = [
"anyhow",
"futures",
@@ -1994,6 +2052,28 @@ dependencies = [
"plotters-backend",
]
[[package]]
name = "pool-service"
version = "4.0.2"
dependencies = [
"anyhow",
"clap",
"constant_time_eq",
"env_logger",
"hex",
"log",
"pow-core",
"primitive-types 0.13.1",
"quantus-miner-api",
"quic-transport",
"quinn",
"rand 0.9.2",
"serde",
"serde_json",
"tokio",
"warp",
]
[[package]]
name = "portable-atomic"
version = "1.13.1"
@@ -2020,10 +2100,11 @@ dependencies = [
[[package]]
name = "pow-core"
version = "3.3.1"
version = "4.0.2"
dependencies = [
"hex",
"primitive-types 0.13.1",
"qp-poseidon-core 3.1.0",
"qpow-math",
]
@@ -2231,6 +2312,12 @@ version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78172860cd960773c72e97fba07ead9e5a1c13086c5746283744933e2e4a577b"
[[package]]
name = "qp-poseidon-core"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5872607e25ea4ee5fb37e64bf1462168e1a36a4e719cdc8a105533c708253918"
[[package]]
name = "qpow-math"
version = "0.1.0"
@@ -2239,19 +2326,33 @@ dependencies = [
"hex",
"log",
"primitive-types 0.13.1",
"qp-poseidon-core",
"qp-poseidon-core 2.1.0",
]
[[package]]
name = "quantus-miner-api"
version = "0.2.0"
source = "git+https://github.com/Quantus-Network/chain.git?tag=v0.7.1-q-day-2#5ea94cb1e5347b12d091b6fa9f9234ceb897ec42"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "725bbf1de0bc7d2ace83f8e49ea754be5f45b84bc9471ddd3f417a9e2bb20553"
dependencies = [
"serde",
"serde_json",
"tokio",
]
[[package]]
name = "quic-transport"
version = "4.0.2"
dependencies = [
"anyhow",
"quantus-miner-api",
"quinn",
"rustls 0.21.12",
"serde_json",
"sha2",
"tokio",
]
[[package]]
name = "quinn"
version = "0.10.2"
@@ -2751,6 +2852,17 @@ dependencies = [
"digest",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest",
]
[[package]]
name = "shlex"
version = "1.3.0"
@@ -2808,6 +2920,14 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "solver-wasm"
version = "4.0.2"
dependencies = [
"primitive-types 0.13.1",
"qpow-math",
]
[[package]]
name = "spin"
version = "0.5.2"
@@ -3633,7 +3753,7 @@ dependencies = [
"js-sys",
"khronos-egl",
"libc",
"libloading",
"libloading 0.8.9",
"log",
"metal",
"naga",

View File

@@ -1,12 +1,17 @@
[workspace]
members = [
"crates/bench-harness", # lair: quantus/miner#2
"crates/engine-cpu",
"crates/engine-cuda", # lair: quantus/miner#3
"crates/engine-gpu",
"crates/metrics",
"crates/miner-cli",
"crates/miner-service",
"crates/miner-telemetry",
"crates/pool-service",
"crates/pow-core",
"crates/quic-transport",
"crates/solver-wasm",
]
default-members = ["crates/miner-cli"]
resolver = "2"
@@ -15,7 +20,7 @@ resolver = "2"
edition = "2021"
authors = ["Quantus Network"]
description = "Quantus External Miner Workspace"
version = "3.3.1"
version = "4.0.2"
[workspace.dependencies]
anyhow = "1"
@@ -29,9 +34,9 @@ num-bigint = { version = "0.4", features = ["rand"] }
num-traits = "0.2"
num_cpus = "1.16"
primitive-types = { version = "0.13.1", default-features = false }
qp-poseidon-core = { version = "2.1.0", default-features = false }
qp-poseidon-core = { version = "3.1.0", default-features = false }
qpow-math = { git = "https://github.com/Quantus-Network/chain.git", tag = "v0.7.1-q-day-2", package = "qpow-math", default-features = false }
quantus-miner-api = { git = "https://github.com/Quantus-Network/chain.git", tag = "v0.7.1-q-day-2" }
quantus-miner-api = "0.3.0"
rand = { version = "0.9", default-features = false, features = ["std", "std_rng", "thread_rng"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "1.0.132", default-features = false }

View File

@@ -1,54 +0,0 @@
# syntax=docker/dockerfile:1
############################
# Builder stage
############################
FROM rust:1.85-slim-bookworm AS builder
# Install build dependencies
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
pkg-config \
libssl-dev \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
# Copy workspace files
COPY Cargo.toml Cargo.lock rust-toolchain taplo.toml ./
COPY crates ./crates
# Build the miner-cli in release mode
RUN cargo build --release -p miner-cli --locked
# Strip debug symbols to reduce binary size
RUN strip target/release/quantus-miner || true
############################
# Runtime-only stage
############################
FROM debian:bookworm-slim
# Install runtime dependencies
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Copy binary from builder stage
COPY --from=builder /build/target/release/quantus-miner /usr/local/bin/quantus-miner
# Expose miner API port and metrics port
EXPOSE 9833 9900
# Run as unprivileged user
RUN useradd --system --uid 10001 quantus
USER 10001:10001
# Default working directory
WORKDIR /data
# Start the miner
ENTRYPOINT ["quantus-miner"]

View File

@@ -1,235 +0,0 @@
# External Miner Protocol Specification
This document defines the QUIC-based protocol for communication between the Quantus Network node and external QPoW miner services.
## Overview
The node delegates the mining task (finding a valid nonce) to external miner services over persistent QUIC connections. The node provides the necessary parameters (header hash, difficulty) and each external miner independently searches for a valid nonce according to the QPoW rules defined in the `qpow-math` crate. Miners push results back when found.
### Key Benefits of QUIC
- **Lower latency**: Results are pushed immediately when found (no polling)
- **Connection resilience**: Built-in connection migration and recovery
- **Multiplexed streams**: Multiple operations on single connection
- **Built-in TLS**: Encrypted by default
## Architecture
### Connection Model
```
┌─────────────────────────────────┐
│ Node │
│ (QUIC Server on port 9833) │
│ │
┌──────────┐ │ Broadcasts: NewJob │
│ Miner 1 │ ──connect───► │ Receives: JobResult │
└──────────┘ │ │
│ Supports multiple miners │
┌──────────┐ │ First valid result wins │
│ Miner 2 │ ──connect───► │ │
└──────────┘ └─────────────────────────────────┘
┌──────────┐
│ Miner 3 │ ──connect───►
└──────────┘
```
- **Node** acts as the QUIC server, listening on port 9833 (default)
- **Miners** act as QUIC clients, connecting to the node
- Single bidirectional stream per miner connection
- Connection persists across multiple mining jobs
- Multiple miners can connect simultaneously
### Multi-Miner Operation
When multiple miners are connected:
1. Node broadcasts the same `NewJob` to all connected miners
2. Each miner independently selects a random starting nonce
3. First miner to find a valid solution sends `JobResult`
4. Node uses the first valid result, ignores subsequent results for same job
5. New job broadcast implicitly cancels work on all miners
### Message Types
The protocol uses only **two message types**:
| Direction | Message | Description |
|-----------|---------|-------------|
| Node → Miner | `NewJob` | Submit a mining job (implicitly cancels any previous job) |
| Miner → Node | `JobResult` | Mining result (completed, failed, or cancelled) |
### Wire Format
Messages are length-prefixed JSON:
```
┌─────────────────┬─────────────────────────────────┐
│ Length (4 bytes)│ JSON payload (MinerMessage) │
│ big-endian u32 │ │
└─────────────────┴─────────────────────────────────┘
```
Maximum message size: 16 MB
## Data Types
See the `quantus-miner-api` crate for the canonical Rust definitions.
### MinerMessage (Enum)
```rust
pub enum MinerMessage {
NewJob(MiningRequest),
JobResult(MiningResult),
}
```
### MiningRequest
| Field | Type | Description |
|-------|------|-------------|
| `job_id` | String | Unique identifier (UUID recommended) |
| `mining_hash` | String | Header hash (64 hex chars, no 0x prefix) |
| `distance_threshold` | String | Difficulty (U512 as decimal string) |
Note: Nonce range is not specified - each miner independently selects a random starting point.
### MiningResult
| Field | Type | Description |
|-------|------|-------------|
| `status` | ApiResponseStatus | Result status (see below) |
| `job_id` | String | Job identifier |
| `nonce` | Option<String> | Winning nonce (U512 hex, no 0x prefix) |
| `work` | Option<String> | Winning nonce as bytes (128 hex chars) |
| `hash_count` | u64 | Number of nonces checked |
| `elapsed_time` | f64 | Time spent mining (seconds) |
### ApiResponseStatus (Enum)
| Value | Description |
|-------|-------------|
| `completed` | Valid nonce found |
| `failed` | Nonce range exhausted without finding solution |
| `cancelled` | Job was cancelled (new job received) |
| `running` | Job still in progress (not typically sent) |
## Protocol Flow
### Normal Mining Flow
```
Miner Node
│ │
│──── QUIC Connect ─────────────────────────►│
│◄─── Connection Established ────────────────│
│ │
│◄─── NewJob { job_id: "abc", ... } ─────────│
│ │
│ (picks random nonce, starts mining) │
│ │
│──── JobResult { job_id: "abc", ... } ─────►│ (found solution!)
│ │
│ (node submits block, gets new work) │
│ │
│◄─── NewJob { job_id: "def", ... } ─────────│
│ │
```
### Job Cancellation (Implicit)
When a new block arrives before the miner finds a solution, the node simply sends a new `NewJob`. The miner automatically cancels the previous job:
```
Miner Node
│ │
│◄─── NewJob { job_id: "abc", ... } ─────────│
│ │
│ (mining "abc") │
│ │
│ (new block arrives at node!) │
│ │
│◄─── NewJob { job_id: "def", ... } ─────────│
│ │
│ (cancels "abc", starts "def") │
│ │
│──── JobResult { job_id: "def", ... } ─────►│
```
### Miner Connect During Active Job
When a miner connects while a job is active, it immediately receives the current job:
```
Miner (new) Node
│ │ (already mining job "abc")
│──── QUIC Connect ─────────────────────────►│
│◄─── Connection Established ────────────────│
│◄─── NewJob { job_id: "abc", ... } ─────────│ (current job sent immediately)
│ │
│ (joins mining effort) │
```
### Stale Result Handling
If a result arrives for an old job, the node discards it:
```
Miner Node
│ │
│◄─── NewJob { job_id: "abc", ... } ─────────│
│ │
│◄─── NewJob { job_id: "def", ... } ─────────│ (almost simultaneous)
│ │
│──── JobResult { job_id: "abc", ... } ─────►│ (stale, node ignores)
│ │
│──── JobResult { job_id: "def", ... } ─────►│ (current, node uses)
```
## Configuration
### Node
```bash
# Listen for external miner connections on port 9833
quantus-node --miner-listen-port 9833
```
### Miner
```bash
# Connect to node
quantus-miner serve --node-addr 127.0.0.1:9833
```
## TLS Configuration
The node generates a self-signed TLS certificate at startup. The miner skips certificate verification by default (insecure mode). For production deployments, consider:
1. **Certificate pinning**: Configure the miner to accept only specific certificate fingerprints
2. **Proper CA**: Use certificates signed by a trusted CA
3. **Network isolation**: Run node and miner on a private network
## Error Handling
### Connection Loss
The miner automatically reconnects with exponential backoff:
- Initial delay: 1 second
- Maximum delay: 30 seconds
The node continues operating with remaining connected miners.
### Validation Errors
If the miner receives an invalid `MiningRequest`, it sends a `JobResult` with status `failed`.
## Notes
- All hex values should be sent **without** the `0x` prefix
- The miner implements validation logic from `qpow_math::is_valid_nonce`
- The node uses the `work` field from `MiningResult` to construct `QPoWSeal`
- ALPN protocol identifier: `quantus-miner`
- Each miner independently generates a random nonce starting point using cryptographically secure randomness
- With a 512-bit nonce space, collision between miners is statistically impossible

View File

@@ -16,18 +16,34 @@ The binary will be available at `target/release/quantus-miner`.
## Running
```bash
# CPU-only mining (default: auto-detected CPU cores)
./target/release/quantus-miner serve --cpu-workers 4
The node requires a shared auth token and TLS cert pin. Both live under the
node's chain config dir (`<base-path>/chains/<chain>/`):
# GPU-only mining
./target/release/quantus-miner serve --gpu-devices 1
- `miner-auth-token` — shared secret (**not** logged by the node; read this file)
- `miner-tls-cert-sha256` — SHA-256 of the miner TLS cert (also printed in node logs)
```bash
# Preferred: mount/read the node's chain config files
./target/release/quantus-miner serve \
--node-addr 127.0.0.1:9833 \
--auth-token-file /path/to/miner-auth-token \
--tls-cert-sha256-file /path/to/miner-tls-cert-sha256 \
--cpu-workers 4
# Or pass values directly (token from miner-auth-token; fingerprint also in node logs)
./target/release/quantus-miner serve \
--node-addr 127.0.0.1:9833 \
--auth-token <TOKEN> \
--tls-cert-sha256 <FINGERPRINT> \
--gpu-devices 1
# Hybrid CPU+GPU mining
./target/release/quantus-miner serve --cpu-workers 4 --gpu-devices 1
# Custom port and metrics
./target/release/quantus-miner serve --cpu-workers 2 --port 8000 --metrics-port 9900
./target/release/quantus-miner serve \
--node-addr 127.0.0.1:9833 \
--auth-token-file /path/to/miner-auth-token \
--tls-cert-sha256-file /path/to/miner-tls-cert-sha256 \
--cpu-workers 4 \
--gpu-devices 1
```
## Configuration
@@ -35,6 +51,10 @@ The binary will be available at `target/release/quantus-miner`.
| Argument | Environment Variable | Description | Default |
|----------|---------------------|-------------|---------|
| `--node-addr <ADDR>` | `MINER_NODE_ADDR` | Node address to connect to | `127.0.0.1:9833` |
| `--auth-token <TOKEN>` | `MINER_AUTH_TOKEN` | Shared secret from the node's `miner-auth-token` file (not logged) | required |
| `--auth-token-file <PATH>` | `MINER_AUTH_TOKEN_FILE` | Read the shared secret from a file (preferred) | — |
| `--tls-cert-sha256 <HEX>` | `MINER_TLS_CERT_SHA256` | SHA-256 of the node's miner TLS cert (`miner-tls-cert-sha256` / node logs) | required |
| `--tls-cert-sha256-file <PATH>` | `MINER_TLS_CERT_SHA256_FILE` | Read the TLS cert fingerprint from a file | — |
| `--cpu-workers <N>` | `MINER_CPU_WORKERS` | Number of CPU worker threads | Auto-detect |
| `--gpu-devices <N>` | `MINER_GPU_DEVICES` | Number of GPU devices | Auto-detect |
| `--gpu-batch-size <N>` | `MINER_GPU_BATCH_SIZE` | GPU batch size in nonces | 1000000 |
@@ -70,27 +90,46 @@ cargo build -p miner-cli --release
## Examples
All `serve` examples need the auth token and TLS pin (files or inline values).
```bash
# CPU mining with 8 workers
./target/release/quantus-miner serve --cpu-workers 8
./target/release/quantus-miner serve \
--auth-token-file /path/to/miner-auth-token \
--tls-cert-sha256-file /path/to/miner-tls-cert-sha256 \
--cpu-workers 8
# Pure GPU mining
./target/release/quantus-miner serve --gpu-devices 1
./target/release/quantus-miner serve \
--auth-token-file /path/to/miner-auth-token \
--tls-cert-sha256-file /path/to/miner-tls-cert-sha256 \
--gpu-devices 1
# GPU mining with throttle (reduce GPU utilization)
./target/release/quantus-miner serve --gpu-devices 1 --gpu-throttle-ms 50
./target/release/quantus-miner serve \
--auth-token-file /path/to/miner-auth-token \
--tls-cert-sha256-file /path/to/miner-tls-cert-sha256 \
--gpu-devices 1 --gpu-throttle-ms 50
# Hybrid mining: 4 CPU + 1 GPU workers
./target/release/quantus-miner serve --cpu-workers 4 --gpu-devices 1
./target/release/quantus-miner serve \
--auth-token-file /path/to/miner-auth-token \
--tls-cert-sha256-file /path/to/miner-tls-cert-sha256 \
--cpu-workers 4 --gpu-devices 1
# With verbose logging
RUST_LOG=debug ./target/release/quantus-miner serve --cpu-workers 2 --gpu-devices 1
RUST_LOG=debug ./target/release/quantus-miner serve \
--auth-token-file /path/to/miner-auth-token \
--tls-cert-sha256-file /path/to/miner-tls-cert-sha256 \
--cpu-workers 2 --gpu-devices 1
# Production setup with metrics
./target/release/quantus-miner serve \
--node-addr 127.0.0.1:9833 \
--auth-token-file /path/to/miner-auth-token \
--tls-cert-sha256-file /path/to/miner-tls-cert-sha256 \
--cpu-workers 6 \
--gpu-devices 1 \
--port 9833 \
--metrics-port 9900
```
@@ -98,25 +137,13 @@ RUST_LOG=debug ./target/release/quantus-miner serve --cpu-workers 2 --gpu-device
The miner uses a QUIC-based protocol for communication with the node:
- **Transport**: QUIC with TLS 1.3 (self-signed certificates)
- **Transport**: QUIC with TLS 1.3 (self-signed certificate, pinned by SHA-256)
- **Auth**: `Ready { token }` must match the node's `miner-auth-token`
- **ALPN**: `quantus-miner/2`
- **Port**: 9833 (default)
- **Messages**: `NewJob` (from node) and `JobResult` (from miner)
- **Messages**: `Ready` (miner→node), `NewJob` (node→miner), `JobResult` (miner→node)
For full protocol specification, see `EXTERNAL_MINER_PROTOCOL.md`.
## Docker
```bash
# Quick start
docker pull ghcr.io/quantus-network/quantus-miner:latest
docker run -d -p 9833:9833 -p 9900:9900 \
ghcr.io/quantus-network/quantus-miner:latest \
--cpu-workers 4 --metrics-port 9900
# Build from source
docker build -t quantus-miner .
docker run -d -p 9833:9833 quantus-miner serve --cpu-workers 4
```
For full protocol specification, see the node's `MINING.md`.
## Benchmarking

80
SECURITY.md Normal file
View File

@@ -0,0 +1,80 @@
# Security Policy
Quantus Network takes the security of our users seriously and welcomes reports
from security researchers.
## Reporting a vulnerability
**Please do not open a public issue, pull request, or social-media post for a
security vulnerability.** Public disclosure before a fix is available puts
users' funds at risk. Use one of the private channels below and we will
coordinate a fix and disclosure with you.
1. **GitHub private security advisory (preferred).**
[Report a vulnerability](https://github.com/quantus-network/quantus-miner/security/advisories/new).
This gives you a private, structured thread with the maintainers and is the
fastest way to reach us.
2. **Email.** Send details to **security@quantus.com**.
### What to include
- A clear description of the issue and its security impact.
- A working proof of concept is required. It must run directly against this
project, using a release build or locally built version, and demonstrate the
reported behavior and security impact. Standalone code, mathematical
examples, or simulations that only reproduce the theory without exercising
the project do not satisfy this requirement.
- Step-by-step reproduction instructions.
- Affected platforms and versions.
- Any relevant logs, addresses, or transaction IDs (for on-chain issues).
If you used AI tooling to find or write up the report, please say so.
## Our commitment (safe harbor)
We consider security research conducted in good faith under this policy to be
authorized. We will not pursue or support legal action against researchers who:
- make a good-faith effort to avoid privacy violations, data destruction, and
interruption or degradation of our services;
- only interact with accounts they own or have explicit permission to access;
and
- give us a reasonable opportunity to fix an issue before disclosing it
publicly.
If in doubt about whether an action is authorized, ask us first at
security@quantus.com.
## What to expect
- **Acknowledgement:** within **2 business days**.
- **Triage and initial assessment:** within **7 business days**.
- **Coordinated disclosure:** we aim to ship a fix and coordinate public
disclosure within **90 days** of the report. We will keep you updated on
progress and agree on a disclosure date with you.
- **Credit:** with your permission, we are happy to publicly credit you for the
report once a fix is released.
## Scope
**In scope:** the external mining service (CPU and GPU) in this repository — anything that could lead to theft or misattribution of mining rewards, invalid blocks being produced, or remote code execution or crash via its service API.
**Out of scope:** issues in third-party services or nodes we do not operate;
reports generated solely by automated scanners without a demonstrated impact;
low-severity or informational issues on our marketing and landing websites;
and social-engineering or physical attacks.
## Rewards
At our **sole discretion**, we may offer a reward for a valid report. To be
eligible, a report must be submitted **privately** through one of the channels
above and identify a genuine vulnerability with real impact on users. Trivial
or low-impact findings, automated-scanner output without a working proof of
concept, and already-known issues are **not** eligible. There is no fixed
bounty and no guaranteed payout; whether a report qualifies, and any amount,
are determined solely by Quantus Network.
## Supported versions
Only the latest release is supported; security fixes are delivered in new
versions. Please keep your installation up to date.

7
clippy.sh Executable file
View File

@@ -0,0 +1,7 @@
#!/usr/bin/env bash
# Run the same clippy command as CI (see .github/workflows/ci.yml)
set -euo pipefail
cargo +nightly fmt
taplo format
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings

View File

@@ -0,0 +1,23 @@
[package]
name = "bench-harness"
version.workspace = true
edition.workspace = true
publish = false
description = "lair: reproducible GPU hashrate and parity measurement for the Quantus miner (quantus/miner#2)"
[[bin]]
name = "quantus-bench"
path = "src/main.rs"
[dependencies]
engine-cpu = { path = "../engine-cpu" }
engine-gpu = { path = "../engine-gpu" }
engine-cuda = { path = "../engine-cuda" }
pow-core = { path = "../pow-core" }
primitive-types = { workspace = true }
clap = { workspace = true, features = ["derive", "env"] }
serde = { workspace = true }
serde_json = { workspace = true, features = ["std"] }
rand = { workspace = true }
env_logger = { workspace = true }
log = { workspace = true }

View File

@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# lair: run quantus-bench on a mining host, as gitea_ci, with the miner paused.
#
# Executed over ssh by .gitea/workflows/bench.yaml. Everything that must be
# serialised per host lives here under one flock: Gitea's workflow concurrency
# group did NOT serialise two runs on the same host (observed: the second run's
# scp hit ETXTBSY on the binary the first was executing), so the host is the
# arbiter, not the forge.
#
# usage: bench-on-host.sh <binary> <duration_secs> <runs> <batch_size> <workers> <label> <out_json>
set -euo pipefail
BIN="$1"; DURATION="$2"; RUNS="$3"; BATCH="$4"; WORKERS="$5"; LABEL="$6"; OUT="$7"
DIR=/var/lib/gitea_ci/bench
LOCK="$DIR/.lock"
exec 9>"$LOCK"
if ! flock -w 1800 9; then
echo "another measurement has held $LOCK for 30 minutes; giving up" >&2
exit 1
fi
echo "--- host ---"
hostname -f
limit=$(nvidia-smi --query-gpu=power.limit --format=csv,noheader,nounits | awk 'NR==1{printf "%d", $1}')
echo "power limit: ${limit} W"
"$BIN" --version
echo "--- pause miner ---"
was_active=0
if systemctl is-active --quiet quantus-miner.service; then
was_active=1
sudo systemctl stop quantus-miner.service
else
echo "quantus-miner.service was not active; nothing to pause"
fi
resume() {
if [ "$was_active" = 1 ]; then
echo "--- resume miner ---"
sudo systemctl start quantus-miner.service
systemctl is-active quantus-miner.service
fi
}
trap resume EXIT
# Refuse to measure a card something else is using (on beast that would be
# inference). A few seconds for the miner to release it.
sleep 3
util=$(nvidia-smi --query-gpu=utilization.gpu --format=csv,noheader,nounits | awk 'NR==1{printf "%d", $1}')
echo "utilisation before measuring: ${util}%"
if [ "$util" -gt 5 ]; then
echo "GPU is busy (${util}%) with the miner stopped; refusing to measure" >&2
exit 1
fi
echo "--- measure ---"
cd "$DIR"
RUST_LOG=info "$BIN" \
--duration-secs "$DURATION" --runs "$RUNS" --batch-size "$BATCH" --workers "$WORKERS" \
--expect-power-limit "$limit" --label "$LABEL" --json "$OUT"

View File

@@ -0,0 +1,51 @@
// lair: embed the git commit in the binary so `--version` and the build-info
// metric identify a deployed build by commit, not by the workspace semver
// (which does not change between commits on a branch that deploys on push).
//
// Resolution order:
// 1. MINER_BUILD_SHA in the environment (CI sets it from the checked-out ref)
// 2. `git rev-parse HEAD` of the workspace, with "-dirty" if the tree differs
// 3. "unknown"
use std::process::Command;
fn git(args: &[&str]) -> Option<String> {
let out = Command::new("git").args(args).output().ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8(out.stdout).ok()?;
let s = s.trim();
if s.is_empty() {
None
} else {
Some(s.to_string())
}
}
fn main() {
println!("cargo:rerun-if-env-changed=MINER_BUILD_SHA");
let sha = match std::env::var("MINER_BUILD_SHA") {
Ok(s) if !s.trim().is_empty() => s.trim().to_string(),
_ => match git(&["rev-parse", "--short=12", "HEAD"]) {
Some(head) => {
// Re-run when HEAD moves so a rebuild after a commit picks it up.
if let Some(dir) = git(&["rev-parse", "--git-dir"]) {
println!("cargo:rerun-if-changed={dir}/HEAD");
println!("cargo:rerun-if-changed={dir}/refs/heads");
}
let dirty = git(&["status", "--porcelain", "--untracked-files=no"])
.map(|s| !s.is_empty())
.unwrap_or(false);
if dirty {
format!("{head}-dirty")
} else {
head
}
}
None => "unknown".to_string(),
},
};
println!("cargo:rustc-env=MINER_BUILD_SHA={sha}");
}

View File

@@ -0,0 +1,506 @@
//! lair: `quantus-bench`, the measurement gate for quantus/miner#2.
//!
//! Drives a GPU engine through the same `MinerEngine` trait the miner uses,
//! records hashrate over fixed windows with the GPU's power and clock state
//! captured before and after, verifies GPU/CPU parity on random jobs, and
//! writes one JSON record so runs are comparable across commits.
//!
//! It deliberately measures one card per worker thread, with no node, no
//! QUIC and no job churn: this is the kernel-plus-submission number. The
//! production number, with all of that included, is quantus/miner#9.
use clap::Parser;
use engine_cpu::{AtomicBoolCancelCheck, EngineStatus, MinerEngine, Range};
use engine_cuda::CudaEngine;
use engine_gpu::GpuEngine;
use primitive_types::U512;
use rand::RngCore;
use serde::Serialize;
use std::process::Command;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
/// Reproducible GPU hashrate and parity measurement.
#[derive(Parser, Debug)]
#[command(version = VERSION, about)]
struct Args {
/// Seconds per timed window.
#[arg(long, default_value_t = 30)]
duration_secs: u64,
/// Number of timed windows (median and spread are reported).
#[arg(long, default_value_t = 5)]
runs: usize,
/// Seconds of untimed warm-up before the first window.
#[arg(long, default_value_t = 5)]
warmup_secs: u64,
/// Nonces per GPU batch (the miner's --gpu-batch-size).
#[arg(long, default_value_t = 1_000_000)]
batch_size: u32,
/// GPU engine: auto (cuda if this binary carries a kernel and a driver is
/// present, else wgpu), cuda, or wgpu.
#[arg(long, default_value = "auto")]
engine: String,
/// Worker threads. The engine assigns threads to devices round-robin, so
/// on a multi-card host N threads measure N cards. More threads than cards
/// is allowed on purpose: two threads on one card overlap one thread's
/// readback with the other's dispatch, which is the submission bubble
/// quantus/miner#4 and #5 are about, measured without touching the miner.
#[arg(long, default_value_t = 1)]
workers: usize,
/// Random jobs for the GPU/CPU parity check (0 to skip).
#[arg(long, default_value_t = 25)]
parity_jobs: usize,
/// Refuse to run unless every GPU reports exactly this enforced power
/// limit in watts. Power limit is the largest confound on these cards.
#[arg(long)]
expect_power_limit: Option<u32>,
/// Commit the binary under test was built from.
#[arg(long, default_value = BUILD_SHA)]
commit: String,
/// Free-form label stored in the record (e.g. the PR number).
#[arg(long, default_value = "")]
label: String,
/// Write the JSON record here.
#[arg(long)]
json: Option<std::path::PathBuf>,
}
// Set by build.rs (same resolution as miner-cli's).
const BUILD_SHA: &str = env!("MINER_BUILD_SHA");
const VERSION: &str = concat!(
env!("CARGO_PKG_VERSION"),
" (",
env!("MINER_BUILD_SHA"),
")"
);
#[derive(Serialize, Debug, Clone)]
struct GpuState {
index: u32,
name: String,
driver: String,
power_limit_w: f64,
power_draw_w: f64,
sm_clock_mhz: f64,
/// The memory clock is a confound like the power limit: the miner never
/// touches VRAM, and a card with memory locked low gives the SMs more of
/// the same power budget (lair/quantus#10).
mem_clock_mhz: f64,
temperature_c: f64,
}
#[derive(Serialize, Debug)]
struct WorkerResult {
worker: usize,
/// MH/s per timed window, in order.
windows_mhs: Vec<f64>,
median_mhs: f64,
/// (max - min) / median over the windows.
spread: f64,
}
#[derive(Serialize, Debug)]
struct Parity {
jobs: usize,
found: usize,
ok: bool,
}
#[derive(Serialize, Debug)]
struct Record {
schema: u32,
timestamp_unix: u64,
host: String,
commit: String,
label: String,
engine: String,
batch_size: u32,
duration_secs: u64,
runs: usize,
warmup_secs: u64,
workers: usize,
gpu_before: Vec<GpuState>,
gpu_after: Vec<GpuState>,
results: Vec<WorkerResult>,
/// Sum of worker medians: the host's number.
total_median_mhs: f64,
parity: Option<Parity>,
}
/// Either GPU engine behind the same trait; the harness drives them identically.
enum Engine {
Wgpu(GpuEngine),
Cuda(CudaEngine),
}
impl Engine {
fn open(kind: &str, batch_size: u32) -> Engine {
match kind {
"cuda" => {
Engine::Cuda(CudaEngine::try_new(batch_size, 0).expect("CUDA engine init failed"))
}
"wgpu" => Engine::Wgpu(
GpuEngine::try_new(batch_size, 0, false).expect("GPU engine init failed"),
),
"auto" => match CudaEngine::try_new(batch_size, 0) {
Ok(e) => Engine::Cuda(e),
Err(e) => {
log::info!("CUDA engine unavailable ({e}); using wgpu");
Engine::Wgpu(
GpuEngine::try_new(batch_size, 0, false).expect("GPU engine init failed"),
)
}
},
other => panic!("unknown --engine {other}; use auto, cuda or wgpu"),
}
}
fn device_count(&self) -> usize {
match self {
Engine::Wgpu(e) => e.device_count(),
Engine::Cuda(e) => e.device_count(),
}
}
fn as_dyn(&self) -> &dyn MinerEngine {
match self {
Engine::Wgpu(e) => e,
Engine::Cuda(e) => e,
}
}
}
fn nvidia_smi() -> Vec<GpuState> {
let out = Command::new("nvidia-smi")
.args([
"--query-gpu=index,name,driver_version,power.limit,power.draw,clocks.sm,clocks.mem,temperature.gpu",
"--format=csv,noheader,nounits",
])
.output();
let Ok(out) = out else {
log::warn!("nvidia-smi not available; GPU state not recorded");
return Vec::new();
};
if !out.status.success() {
log::warn!("nvidia-smi failed; GPU state not recorded");
return Vec::new();
}
String::from_utf8_lossy(&out.stdout)
.lines()
.filter_map(|l| {
let f: Vec<&str> = l.split(',').map(str::trim).collect();
if f.len() != 8 {
return None;
}
let num = |s: &str| s.parse::<f64>().unwrap_or(f64::NAN);
Some(GpuState {
index: f[0].parse().ok()?,
name: f[1].to_string(),
driver: f[2].to_string(),
power_limit_w: num(f[3]),
power_draw_w: num(f[4]),
sm_clock_mhz: num(f[5]),
mem_clock_mhz: num(f[6]),
temperature_c: num(f[7]),
})
})
.collect()
}
fn hostname() -> String {
Command::new("hostname")
.arg("-f")
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_else(|| "unknown".into())
}
fn median(xs: &[f64]) -> f64 {
let mut v = xs.to_vec();
v.sort_by(|a, b| a.partial_cmp(b).unwrap());
let n = v.len();
if n == 0 {
return f64::NAN;
}
if n % 2 == 1 {
v[n / 2]
} else {
(v[n / 2 - 1] + v[n / 2]) / 2.0
}
}
/// One search window: search from a random start until `cancel` fires,
/// returning hashes and elapsed seconds. The difficulty is high enough that
/// a solution is effectively impossible, matching production where the second
/// squeeze is almost never taken.
fn window(engine: &dyn MinerEngine, secs: u64, rng: &mut impl RngCore) -> (u64, f64) {
let mut header = [0u8; 32];
rng.fill_bytes(&mut header);
let ctx = engine.prepare_context(header, U512::from(1u64) << 200);
let mut start_bytes = [0u8; 64];
rng.fill_bytes(&mut start_bytes);
start_bytes[0] = 0; // keep clear of the top so the range cannot wrap
let start = U512::from_big_endian(&start_bytes);
let range = Range {
start,
end: start + (U512::from(1u64) << 200),
};
let flag = Arc::new(AtomicBool::new(false));
let timer_flag = flag.clone();
let timer = std::thread::spawn(move || {
std::thread::sleep(Duration::from_secs(secs));
timer_flag.store(true, Ordering::Relaxed);
});
let t0 = Instant::now();
let status = engine.search_range(&ctx, range, &AtomicBoolCancelCheck(&flag));
let elapsed = t0.elapsed().as_secs_f64();
timer.join().expect("timer thread");
let hashes = match status {
EngineStatus::Cancelled { hash_count } | EngineStatus::Exhausted { hash_count } => {
hash_count
}
EngineStatus::Found { hash_count, .. } => {
log::warn!("window found a solution at 2^-200 odds; counting hashes anyway");
hash_count
}
other => panic!("unexpected engine status: {other:?}"),
};
(hashes, elapsed)
}
fn parity(engine: &dyn MinerEngine, jobs: usize) -> Parity {
// Same shape as engine-gpu's gpu_cpu_parity example, kept in lockstep with it.
let mut rng = rand::rng();
let cancel_flag = AtomicBool::new(false);
let cancel = AtomicBoolCancelCheck(&cancel_flag);
let mut found = 0usize;
let mut ok = true;
for job in 0..jobs {
let mut header = [0u8; 32];
rng.fill_bytes(&mut header);
let ctx = engine.prepare_context(header, U512::from(100_000u64));
let start = if job == 0 {
// Cross a 2^256 boundary to exercise the midstate batch clamp.
(U512::from(3u64) << 256) - U512::from(1_000u64)
} else {
let mut b = [0u8; 64];
rng.fill_bytes(&mut b);
b[0] = 0;
U512::from_big_endian(&b)
};
let range = Range {
start,
end: start + U512::from(10_000_000u64),
};
match engine.search_range(&ctx, range.clone(), &cancel) {
EngineStatus::Found { candidate, .. } => {
let cpu = pow_core::hash_from_nonce(&ctx, candidate.nonce);
let in_range = candidate.nonce >= range.start && candidate.nonce <= range.end;
if cpu != candidate.hash || cpu >= ctx.target || !in_range {
log::error!(
"parity job {job}: nonce {} gpu_hash {} cpu_hash {} in_range {in_range}",
candidate.nonce,
candidate.hash,
cpu
);
ok = false;
}
found += 1;
}
EngineStatus::Exhausted { .. } => {}
other => {
log::error!("parity job {job}: unexpected status {other:?}");
ok = false;
}
}
}
if found == 0 {
log::error!("parity: no solutions found across {jobs} jobs");
ok = false;
}
Parity { jobs, found, ok }
}
fn main() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let args = Args::parse();
if args.runs == 0 || args.workers == 0 {
eprintln!("--runs and --workers must be at least 1");
std::process::exit(2);
}
let gpu_before = nvidia_smi();
if let Some(want) = args.expect_power_limit {
for g in &gpu_before {
if (g.power_limit_w - want as f64).abs() > 0.5 {
eprintln!(
"gpu {} ({}) power limit is {:.0} W, expected {want} W; refusing to measure",
g.index, g.name, g.power_limit_w
);
std::process::exit(3);
}
}
}
let engine = Arc::new(Engine::open(&args.engine, args.batch_size));
let devices = engine.device_count();
if args.workers > devices {
log::warn!(
"{} workers on {devices} device(s): threads share cards, so per-worker numbers \
are shares and total_median_mhs is the number to compare",
args.workers
);
}
log::info!(
"engine {} with {devices} device(s); measuring {} worker(s), {} x {}s windows after {}s warm-up, batch {}",
engine.as_dyn().name(),
args.workers,
args.runs,
args.duration_secs,
args.warmup_secs,
args.batch_size
);
let mut handles = Vec::new();
for w in 0..args.workers {
let engine = engine.clone();
let (runs, dur, warm) = (args.runs, args.duration_secs, args.warmup_secs);
handles.push(std::thread::spawn(move || {
let mut rng = rand::rng();
if warm > 0 {
let _ = window(engine.as_dyn(), warm, &mut rng);
}
let mut windows = Vec::with_capacity(runs);
for i in 0..runs {
let (h, s) = window(engine.as_dyn(), dur, &mut rng);
let mhs = h as f64 / s / 1e6;
log::info!("worker {w} window {i}: {h} hashes in {s:.2}s = {mhs:.2} MH/s");
windows.push(mhs);
}
let med = median(&windows);
let (mn, mx) = windows
.iter()
.fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), &x| {
(a.min(x), b.max(x))
});
WorkerResult {
worker: w,
windows_mhs: windows,
median_mhs: med,
spread: if med > 0.0 { (mx - mn) / med } else { f64::NAN },
}
}));
}
let results: Vec<WorkerResult> = handles
.into_iter()
.map(|h| h.join().expect("worker thread"))
.collect();
let gpu_after = nvidia_smi();
let parity_result = if args.parity_jobs > 0 {
Some(parity(engine.as_dyn(), args.parity_jobs))
} else {
None
};
let record = Record {
schema: 2,
timestamp_unix: SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0),
host: hostname(),
commit: args.commit.clone(),
label: args.label.clone(),
engine: engine.as_dyn().name().to_string(),
batch_size: args.batch_size,
duration_secs: args.duration_secs,
runs: args.runs,
warmup_secs: args.warmup_secs,
workers: args.workers,
gpu_before,
gpu_after,
total_median_mhs: results.iter().map(|r| r.median_mhs).sum(),
results,
parity: parity_result,
};
// Human summary on stdout (the workflow appends it to the job summary).
println!("## quantus-bench {} on {}", record.commit, record.host);
println!();
println!("| worker | median MH/s | spread | windows |");
println!("| --- | --- | --- | --- |");
for r in &record.results {
let w: Vec<String> = r.windows_mhs.iter().map(|x| format!("{x:.1}")).collect();
println!(
"| {} | {:.2} | {:.1}% | {} |",
r.worker,
r.median_mhs,
r.spread * 100.0,
w.join(", ")
);
}
println!();
println!(
"total median: **{:.2} MH/s** (batch {}, {} x {}s, engine {})",
record.total_median_mhs,
record.batch_size,
record.runs,
record.duration_secs,
record.engine
);
for g in &record.gpu_after {
println!(
"- gpu {} {}: driver {}, limit {:.0} W, draw {:.0} W, sm {:.0} MHz, mem {:.0} MHz, {:.0} C",
g.index,
g.name,
g.driver,
g.power_limit_w,
g.power_draw_w,
g.sm_clock_mhz,
g.mem_clock_mhz,
g.temperature_c
);
}
if let Some(p) = &record.parity {
println!(
"- parity: {} ({}/{} jobs found solutions, all verified against CPU)",
if p.ok { "OK" } else { "FAILED" },
p.found,
p.jobs
);
}
if let Some(path) = &args.json {
let s = serde_json::to_string_pretty(&record).expect("serialise record");
std::fs::write(path, s).expect("write json record");
log::info!("record written to {}", path.display());
}
let spread_bad = record.results.iter().any(|r| r.spread > 0.02);
if spread_bad {
eprintln!("spread above 2% on at least one worker; run is not stable enough to compare");
}
if record.parity.as_ref().is_some_and(|p| !p.ok) {
eprintln!("PARITY FAILED");
std::process::exit(4);
}
if spread_bad {
std::process::exit(5);
}
}

View File

@@ -0,0 +1,18 @@
[package]
name = "engine-cuda"
version.workspace = true
edition.workspace = true
publish = false
description = "lair: native CUDA mining engine behind MinerEngine (quantus/miner#3)"
[dependencies]
engine-cpu = { path = "../engine-cpu" }
pow-core = { path = "../pow-core" }
metrics = { path = "../metrics" }
primitive-types = { workspace = true }
log = { workspace = true }
cudarc = { version = "0.19", default-features = false, features = ["driver", "nvrtc", "dynamic-loading", "cuda-13000"] }
qp-poseidon-constants = "1.1"
[build-dependencies]
qp-poseidon-constants = "1.1"

196
crates/engine-cuda/build.rs Normal file
View File

@@ -0,0 +1,196 @@
//! lair: build the CUDA mining kernel into a fat binary (quantus/miner#3, #8).
//!
//! 1. Generate `poseidon2_constants.cuh` from `qp-poseidon-constants`, so a
//! change to the hash at origin reaches the kernel as a dependency bump and
//! never as a hand-copied table (rule from quantus/miner#1).
//! 2. If `nvcc` is available, compile `src/kernels/mining.cu` once with one
//! cubin per fleet architecture plus PTX for the newest, into
//! `$OUT_DIR/mining.fatbin`. The driver picks the matching cubin at load;
//! an unknown future card JIT-compiles the PTX.
//! 3. If `nvcc` is not available (the `rust` lint runner, a workstation without
//! CUDA), write an empty fat binary and warn. The crate still compiles;
//! `CudaEngine::try_new` then fails with a clear message and the miner
//! falls back to the wgpu engine.
//!
//! Knobs (environment):
//! NVCC / CUDA_HOME where nvcc is
//! MINER_CUDA_ARCHS comma-separated SM list, default 86,89,120
//! MINER_NVCC_CCBIN host compiler for nvcc (-ccbin)
//! MINER_CUDA_ALLOW_UNSUPPORTED_COMPILER=1 pass -allow-unsupported-compiler
//! MINER_CUDA_REQUIRE=1 fail the build instead of warning when nvcc is missing
//! MINER_NVCC_FLAGS extra nvcc arguments (whitespace-separated), e.g. -DLAIR_LAZY_ADD=0
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
fn write_constants(out: &Path) {
use qp_poseidon_constants as c;
let mut s = String::new();
s.push_str("// Generated by engine-cuda/build.rs from qp-poseidon-constants. Do not edit.\n");
s.push_str("#pragma once\n");
let arr = |name: &str, v: &[u64]| {
let body: Vec<String> = v.iter().map(|x| format!("0x{x:016x}ull")).collect();
format!(
"__device__ __constant__ unsigned long long {}[{}] = {{\n {}\n}};\n",
name,
v.len(),
body.join(",\n ")
)
};
let arr2 = |name: &str, v: &[[u64; 12]]| {
let rows: Vec<String> = v
.iter()
.map(|r| {
let body: Vec<String> = r.iter().map(|x| format!("0x{x:016x}ull")).collect();
format!(" {{{}}}", body.join(", "))
})
.collect();
format!(
"__device__ __constant__ unsigned long long {}[{}][12] = {{\n{}\n}};\n",
name,
v.len(),
rows.join(",\n")
)
};
s.push_str(&arr("RC_INTERNAL", &c::POSEIDON2_INTERNAL_CONSTANTS_RAW));
s.push_str(&arr2(
"RC_INITIAL",
&c::POSEIDON2_INITIAL_EXTERNAL_CONSTANTS_RAW,
));
s.push_str(&arr2(
"RC_TERMINAL",
&c::POSEIDON2_TERMINAL_EXTERNAL_CONSTANTS_RAW,
));
s.push_str(&arr("MDS_DIAG", &c::POSEIDON2_MATRIX_DIAG_12_RAW));
s.push_str(&format!(
"#define SPONGE_WIDTH {}\n#define N_EXTERNAL_HALF {}\n#define N_INTERNAL {}\n",
c::SPONGE_WIDTH,
c::POSEIDON2_EXTERNAL_ROUNDS / 2,
c::POSEIDON2_INTERNAL_ROUNDS
));
fs::write(out.join("poseidon2_constants.cuh"), s).expect("write constants header");
}
fn find_nvcc() -> Option<PathBuf> {
if let Ok(p) = env::var("NVCC") {
let p = PathBuf::from(p);
if p.is_file() {
return Some(p);
}
}
if let Ok(home) = env::var("CUDA_HOME").or_else(|_| env::var("CUDA_PATH")) {
let p = PathBuf::from(home).join("bin").join("nvcc");
if p.is_file() {
return Some(p);
}
}
if let Ok(path) = env::var("PATH") {
for dir in env::split_paths(&path) {
let p = dir.join("nvcc");
if p.is_file() {
return Some(p);
}
}
}
// The toolkit the fleet drivers support; prefer an exact 13.0 over "latest".
for cand in [
"/usr/local/cuda-13.0",
"/usr/local/cuda-13",
"/usr/local/cuda",
] {
let p = PathBuf::from(cand).join("bin").join("nvcc");
if p.is_file() {
return Some(p);
}
}
None
}
fn main() {
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed=src/kernels/mining.cu");
for v in [
"NVCC",
"CUDA_HOME",
"CUDA_PATH",
"MINER_CUDA_ARCHS",
"MINER_NVCC_CCBIN",
"MINER_CUDA_ALLOW_UNSUPPORTED_COMPILER",
"MINER_CUDA_REQUIRE",
"MINER_NVCC_FLAGS",
] {
println!("cargo:rerun-if-env-changed={v}");
}
let out = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR"));
write_constants(&out);
let archs = env::var("MINER_CUDA_ARCHS").unwrap_or_else(|_| "86,89,120".to_string());
let sms: Vec<String> = archs
.split(',')
.map(|s| s.trim().trim_start_matches("sm_").to_string())
.filter(|s| !s.is_empty())
.collect();
println!("cargo:rustc-env=MINER_CUDA_ARCHS={}", sms.join(","));
let fatbin = out.join("mining.fatbin");
let Some(nvcc) = find_nvcc() else {
if env::var("MINER_CUDA_REQUIRE")
.map(|v| v == "1")
.unwrap_or(false)
{
panic!("nvcc not found and MINER_CUDA_REQUIRE=1; set NVCC or CUDA_HOME");
}
println!("cargo:warning=engine-cuda: nvcc not found; CUDA kernel not compiled into this binary (wgpu engine will be used)");
fs::write(&fatbin, []).expect("write empty fatbin");
return;
};
let mut cmd = Command::new(&nvcc);
cmd.arg("-fatbin")
.arg("-O3")
.arg("-std=c++17")
.arg("-I")
.arg(&out)
.arg("-o")
.arg(&fatbin);
if let Ok(cc) = env::var("MINER_NVCC_CCBIN") {
cmd.arg("-ccbin").arg(cc);
}
if env::var("MINER_CUDA_ALLOW_UNSUPPORTED_COMPILER")
.map(|v| v == "1")
.unwrap_or(false)
{
cmd.arg("-allow-unsupported-compiler");
}
if let Ok(extra) = env::var("MINER_NVCC_FLAGS") {
for a in extra.split_whitespace() {
cmd.arg(a);
}
}
for sm in &sms {
cmd.arg("--generate-code")
.arg(format!("arch=compute_{sm},code=sm_{sm}"));
}
if let Some(newest) = sms.iter().max_by_key(|s| s.parse::<u32>().unwrap_or(0)) {
cmd.arg("--generate-code")
.arg(format!("arch=compute_{newest},code=compute_{newest}"));
}
cmd.arg("src/kernels/mining.cu");
println!(
"cargo:warning=engine-cuda: {} for sm_{{{}}}",
nvcc.display(),
sms.join(",")
);
let status = cmd.status().expect("run nvcc");
if !status.success() {
panic!("nvcc failed ({status}); see output above");
}
let size = fs::metadata(&fatbin).map(|m| m.len()).unwrap_or(0);
if size == 0 {
panic!("nvcc produced an empty fat binary");
}
}

View File

@@ -0,0 +1,876 @@
// lair: Poseidon2-over-Goldilocks mining kernel, native CUDA (quantus/miner#3).
//
// Same host contract as engine-gpu's mining_u64.wgsl, and bit-exact with it and
// with pow_core:
// - the host precomputes the sponge midstate after absorbing the 32-byte
// header and the high 32 bytes of the big-endian nonce (pow_core::
// mining_midstate), so each nonce costs 2 permutations instead of 5;
// - a batch never carries into the high 256 bits of the nonce, so only the
// low 8 u32 limbs are incremented here;
// - the first squeeze yields the most significant 256 bits of the hash, which
// decide hash-vs-target on their own unless they exactly equal the target's
// high half; only such candidates pay for the second squeeze.
//
// What CUDA buys over WGSL: a 64x64 -> 128-bit multiply is `__umul64hi` plus a
// plain multiply, instead of four 32-bit partial products with carry
// reconstruction. The field multiply is the whole cost of this hash.
//
// Field elements are kept in lazy (non-canonical) form below 2^64, exactly as
// the WGSL kernel does, and canonicalised only when bytes are produced.
#include <stdint.h>
#include "poseidon2_constants.cuh"
typedef unsigned long long u64;
typedef unsigned int u32;
#define P64 0xFFFFFFFF00000001ull
// 2^64 mod P = 2^32 - 1
#define EPS 0xFFFFFFFFull
// lair: whether gf_add and gf_reduce should use inline-PTX carry arithmetic or
// plain C is architecture-dependent, and the difference is large in both
// directions. An `asm` block is opaque to nvcc's optimiser, so carry arithmetic
// written as PTX blocks common-subexpression elimination and strength reduction
// across neighbouring field operations. On sm_120 that costs a quarter of the
// kernel; on sm_86/89 the hand-written sequence still wins.
//
// Whole-kernel instructions / ALU ops (ptxas + cuobjdump, CUDA 13.0) and
// measured hashrate, three interleaved rounds each, parity 40/40 vs CPU:
//
// sm_120 sm_86/89
// CARRY=1 37,432 / 26,688 1126.6 37,027 benjy 461.6 quadbrat 85.5
// CARRY=0 27,837 / 19,519 1333.3 41,196 benjy 401.5 quadbrat 76.3
//
// So +18.3% on the 5090s and -13.0% / -10.8% on the 4090 and 3060. Hence the
// per-arch default. Unlike LAIR_INT_UNROLL -- where the static measures pointed
// one way and the cards' power envelope the other -- this one was measured on
// hardware on all three architectures, and the static count predicted the sign
// correctly on each.
//
// LAIR_PTX_ACC stays 1 everywhere: in the Acc accumulators the add-with-carry
// pair is the whole operation, so there is nothing around it to optimise. On
// sm_120, carry off with Acc off measured 1285.8 against 1333.3 with Acc on.
//
// PR #17 measured the PTX carry path as a win on every card, and that was true
// of the kernel it was written for -- which spilled 104 bytes per thread. Once
// the spills went (see MiningUniforms) the trade reversed on Blackwell only.
// Re-measure per architecture before touching this.
#ifndef LAIR_PTX_CARRY
# if defined(__CUDA_ARCH__) && __CUDA_ARCH__ >= 1200
# define LAIR_PTX_CARRY 0
# else
# define LAIR_PTX_CARRY 1
# endif
#endif
#ifndef LAIR_PTX_ACC
#define LAIR_PTX_ACC 1
#endif
// Fold the next round's constant into the linear layer's final reduction
// instead of a separate gf_add per element before the S-box.
#ifndef LAIR_FOLD_RC
#define LAIR_FOLD_RC 1
#endif
#if LAIR_PTX_CARRY
// Carry-flag arithmetic: the wrap is read from the condition code instead of
// a compare, and folded with a multiply-add (c * EPS is exact for c in {0,1}).
__device__ __forceinline__ u64 gf_add(u64 a, u64 b) {
u64 s0, c1, s1, c2;
asm("add.cc.u64 %0, %2, %3;\n\taddc.u64 %1, 0, 0;" : "=l"(s0), "=l"(c1) : "l"(a), "l"(b));
asm("add.cc.u64 %0, %2, %3;\n\taddc.u64 %1, 0, 0;" : "=l"(s1), "=l"(c2) : "l"(s0), "l"(c1 * EPS));
return s1 + c2 * EPS;
}
__device__ __forceinline__ u64 gf_reduce(u64 lo, u64 hi) {
u64 hi_hi = hi >> 32;
u64 hi_lo = hi & EPS;
u64 t0, bm;
// bm = -borrow: all ones when lo < hi_hi.
asm("sub.cc.u64 %0, %2, %3;\n\tsubc.u64 %1, 0, 0;" : "=l"(t0), "=l"(bm) : "l"(lo), "l"(hi_hi));
t0 -= (bm & EPS);
u64 t1 = hi_lo * EPS;
u64 t2, c;
asm("add.cc.u64 %0, %2, %3;\n\taddc.u64 %1, 0, 0;" : "=l"(t2), "=l"(c) : "l"(t0), "l"(t1));
return t2 + c * EPS;
}
#else
// a + b mod P, lazy. A wrapped carry folds back as 2^64 = EPS (mod P); the
// second fold can only be needed when the first one wrapped.
__device__ __forceinline__ u64 gf_add(u64 a, u64 b) {
u64 s0 = a + b;
u64 c1 = s0 < a;
u64 s1 = s0 + (c1 ? EPS : 0ull);
u64 c2 = c1 & (s1 < s0);
return s1 + (c2 ? EPS : 0ull);
}
// Reduce lo + hi * 2^64 mod P using 2^64 = EPS and 2^96 = -1 (mod P).
__device__ __forceinline__ u64 gf_reduce(u64 lo, u64 hi) {
u64 hi_hi = hi >> 32;
u64 hi_lo = hi & EPS;
u64 t0 = lo - hi_hi;
if (lo < hi_hi) t0 -= EPS;
u64 t1 = hi_lo * EPS;
u64 t2 = t0 + t1;
if (t2 < t0) t2 += EPS;
return t2;
}
#endif
// lair: field arithmetic as self-contained PTX blocks (quantus/miner#3).
//
// Measured against the closed-source qpow-cuda 1.0.7 kernel on the same
// silicon: its multiply-plus-reduce is 11 SASS instructions on sm_120 and a
// squaring 10, against ~14 per multiply here, and it folds the internal
// layer's row sum into the diagonal multiply's addend for free. The whole
// difference is in how the 128-bit product is reduced: `mad.lo.cc.u32` +
// `madc.hi.cc.u32` become one IMAD.WIDE.U32 with a carry-out predicate, and
// the 2^96 = -1 term is folded with three more ALU ops. Each block below is
// one complete field operation, so there is nothing across its boundary for
// nvcc to optimise -- unlike LAIR_PTX_CARRY, whose fragments starved the
// optimiser on sm_120.
//
// LAIR_EXACT_REDUCE: the closed-source kernel drops the final borrow of the
// reduction. That is wrong only when the 128-bit product has bits 64..95 all
// zero and its low 64 bits below its top 32 bits: about 2^-64 of multiplies
// with field-element inputs (verified by emulating the chain against a
// reference multiply: 0 mismatches in 3M random products, and the constructed
// product 2^48 * 2^48 = 2^96 does fail). The other apparent hazard, the
// 32-bit wrap of hh + carry, cannot occur: hh = 2^32 - 1 needs both operands
// within 2^32 of 2^64, and then the low half is too small to carry. So with 0
// the kernel computes a wrong hash for about 1472 * 2^-64 ~ 1e-16 of nonces,
// which can neither hide a real solution nor pass a false one in practice
// (a wrong hash still has to beat the target, 2^-47 at mainnet difficulty,
// to be submitted, and the node would reject it); with 1 every hash is
// bit-exact, for three more instructions per multiply. Measured on
// beast's 5090s, three interleaved rounds: 0 = 1996 MH/s, 1 = 1695 MH/s,
// against 1356 for the C reduction -- exactness costs 15%.
#ifndef LAIR_FUSED_MUL
#define LAIR_FUSED_MUL 1
#endif
#ifndef LAIR_EXACT_REDUCE
#define LAIR_EXACT_REDUCE 0
#endif
#if LAIR_FUSED_MUL
// EPS as an operand ptxas cannot see the value of: with an immediate it would
// rewrite `x * EPS` into shift-and-subtract, which loses on this hardware.
__device__ __constant__ u32 LAIR_EPS32 = 0xFFFFFFFFu;
// Reduce {ll, lh} + 2^64 * {hl, hh} in place, lazily (result < 2^64, not
// canonical), using 2^64 = EPS and 2^96 = -1 (mod P). E is the EPS operand.
// {ll,lh} += hl * EPS, carry cy (mad.lo.cc / madc.hi.cc)
// c = hh + cy; lh += cy (+cy * 2^32 ...)
// {ll,lh} -= c (... - cy - hh = +cy*EPS - hh)
#if LAIR_EXACT_REDUCE
#define LAIR_REDUCE_PTX(E) \
"mad.lo.cc.u32 ll, hl, " E ", ll;\n\t" \
"madc.hi.cc.u32 lh, hl, " E ", lh;\n\t" \
"addc.u32 c, hh, 0;\n\t" \
"addc.u32 lh, lh, 0;\n\t" \
"sub.cc.u32 ll, ll, c;\n\t" \
"subc.cc.u32 lh, lh, 0;\n\t" \
"subc.u32 c, 0, 0;\n\t" \
"sub.cc.u32 ll, ll, c;\n\t" \
"subc.u32 lh, lh, 0;\n\t"
#else
#define LAIR_REDUCE_PTX(E) \
"mad.lo.cc.u32 ll, hl, " E ", ll;\n\t" \
"madc.hi.cc.u32 lh, hl, " E ", lh;\n\t" \
"addc.u32 c, hh, 0;\n\t" \
"addc.u32 lh, lh, 0;\n\t" \
"sub.cc.u32 ll, ll, c;\n\t" \
"subc.u32 lh, lh, 0;\n\t"
#endif
__device__ __forceinline__ u64 gf_mul(u64 a, u64 b) {
u64 r;
u32 e = LAIR_EPS32;
asm("{\n\t"
".reg .b32 a0, a1, b0, b1, p0l, p0h, m0, m1, cw, ll, lh, hl, hh, c;\n\t"
".reg .b64 p0, m, m2, p3, t, hi;\n\t"
"mov.b64 {a0, a1}, %1;\n\t"
"mov.b64 {b0, b1}, %2;\n\t"
"mul.wide.u32 p0, a0, b0;\n\t"
"mul.wide.u32 m, a1, b0;\n\t"
"mul.wide.u32 m2, a0, b1;\n\t"
"add.cc.u64 m, m, m2;\n\t"
"addc.u32 cw, 0, 0;\n\t"
"mov.b64 {p0l, p0h}, p0;\n\t"
"mov.b64 {m0, m1}, m;\n\t"
"mov.b64 t, {m1, cw};\n\t"
"mul.wide.u32 p3, a1, b1;\n\t"
"add.cc.u32 lh, p0h, m0;\n\t"
"addc.u64 hi, p3, t;\n\t"
"mov.b64 {hl, hh}, hi;\n\t"
"mov.b32 ll, p0l;\n\t"
LAIR_REDUCE_PTX("%3")
"mov.b64 %0, {ll, lh};\n\t"
"}"
: "=l"(r) : "l"(a), "l"(b), "r"(e));
return r;
}
// a^2 with three 32x32 products: a0^2 + 2*a0*a1*2^32 + a1^2*2^64.
__device__ __forceinline__ u64 gf_sqr(u64 a) {
u64 r;
u32 e = LAIR_EPS32;
asm("{\n\t"
".reg .b32 a0, a1, p0l, p0h, m2l, m2h, c32, ll, lh, hl, hh, c;\n\t"
".reg .b64 p0, m, m2, p3, t, hi;\n\t"
"mov.b64 {a0, a1}, %1;\n\t"
"mul.wide.u32 p0, a0, a0;\n\t"
"mul.wide.u32 m, a0, a1;\n\t"
"shl.b64 m2, m, 1;\n\t"
"shr.u64 t, m, 63;\n\t"
"mov.b64 {m2l, m2h}, m2;\n\t"
"mov.b64 {p0l, p0h}, p0;\n\t"
"add.cc.u32 lh, p0h, m2l;\n\t"
"cvt.u32.u64 c32, t;\n\t"
"mov.b64 t, {m2h, c32};\n\t"
"mul.wide.u32 p3, a1, a1;\n\t"
"addc.u64 hi, p3, t;\n\t"
"mov.b64 {hl, hh}, hi;\n\t"
"mov.b32 ll, p0l;\n\t"
LAIR_REDUCE_PTX("%2")
"mov.b64 %0, {ll, lh};\n\t"
"}"
: "=l"(r) : "l"(a), "r"(e));
return r;
}
#else
__device__ __forceinline__ u64 gf_mul(u64 a, u64 b) {
return gf_reduce(a * b, __umul64hi(a, b));
}
// Squaring deliberately uses the general product. nvcc does not specialise
// `a * a`: it emits the same six IMAD.WIDE in 48 instructions as gf_mul
// (measured, sm_120). The textbook saving is real but does not pay here --
// a = a1*2^32 + a0 gives a^2 = a0^2 + a0*a1*2^33 + a1^2*2^64, three 32x32
// multiplies instead of four, and it was implemented, verified bit-exact
// against __int128 over 40M values, and measured 5.7% SLOWER on beast's 5090s
// (1072.9 vs 1137.7 MH/s, three interleaved rounds). It saves 6.7% of the
// kernel's widening multiplies and costs 11% more instructions: 5 multiplies
// in 56 instructions against 6 in 48. Four formulations (condition-code
// carry, compare carry, and two mad.wide.u32 variants) all compiled to the
// same 5/56, so 56 is the floor. The scarce resource in this kernel is
// instruction issue, not multiply throughput -- which is also why raising
// occupancy lost (see LAIR_INT_UNROLL). Anything that wins here has to remove
// work, not re-express it.
__device__ __forceinline__ u64 gf_sqr(u64 a) {
return gf_reduce(a * a, __umul64hi(a, a));
}
#endif // LAIR_FUSED_MUL
// x^7. Writing the four steps as one PTX block in 32-bit halves was tried
// (2026-09-13) and compiles to byte-identical SASS: the 64-bit pair moves
// between blocks are free, so there is nothing to save here.
__device__ __forceinline__ u64 gf_sbox(u64 x) {
u64 x2 = gf_sqr(x);
u64 x4 = gf_sqr(x2);
u64 x6 = gf_mul(x4, x2);
return gf_mul(x6, x);
}
__device__ __forceinline__ u64 gf_canon(u64 a) {
return a - (a >= P64 ? P64 : 0ull);
}
#ifndef LAIR_LAZY_ADD
#define LAIR_LAZY_ADD 1
#endif
// lair: 2 stays the default on every architecture. Unroll 1 looks better on
// sm_120 by every static measure -- 78 registers instead of 106, no spill
// either way, a third resident block per SM instead of two -- and measured
// 0.92% SLOWER on beast's 5090s (1128.4 vs 1138.8 MH/s, three interleaved
// rounds, spread <=0.1%). These cards are power-bound, not latency-bound: at
// the 400 W cap the extra resident warps buy power draw, and the card clocks
// down to pay for it (2325/2385 MHz against 2355/2415). Occupancy is not the
// lever here; work per hash is. Do not "fix" this without a measurement.
#ifndef LAIR_INT_UNROLL
#define LAIR_INT_UNROLL 2
#endif
// nvcc does not macro-expand `#pragma unroll N`; stringise through _Pragma.
#define LAIR_PRAGMA(x) _Pragma(#x)
#define LAIR_UNROLL(n) LAIR_PRAGMA(unroll n)
#ifndef LAIR_PTX_CARRY
# if defined(__CUDA_ARCH__) && __CUDA_ARCH__ >= 1200
# define LAIR_PTX_CARRY 0
# else
# define LAIR_PTX_CARRY 1
# endif
#endif
#ifndef LAIR_PTX_ACC
#define LAIR_PTX_ACC 1
#endif
// Fold the next round's constant into the linear layer's final reduction
// instead of a separate gf_add per element before the S-box.
#ifndef LAIR_FOLD_RC
#define LAIR_FOLD_RC 1
#endif
#ifndef LAIR_NOINLINE_PERMUTE
#define LAIR_NOINLINE_PERMUTE 0
#endif
// Threads per block; the host's MINER_CUDA_THREADS_PER_BLOCK must agree.
#ifndef LAIR_TPB
#define LAIR_TPB 256
#endif
// Minimum resident blocks per SM the compiler must fit (caps registers at
// 65536 / (LAIR_TPB * LAIR_MINBLOCKS)); 0 leaves the register budget to ptxas.
// Measured 2026-09-13 on beast against the 110-register default (2205 MH/s):
// 64 registers by any route (1024 threads, or 256 x 4, or 512 x 2) spills
// 216-264 bytes and loses 5% (2091-2107); 80 registers (256 x 3, 52 bytes of
// spill) is neutral (2207). The quanpool kernel runs 1024 threads per SM at
// 64 registers without spilling because its rounds are loops with the
// constants in constant memory; this unrolled kernel cannot get there.
#ifndef LAIR_MINBLOCKS
#define LAIR_MINBLOCKS 0
#endif
#if LAIR_MINBLOCKS
#define LAIR_LAUNCH_BOUNDS __launch_bounds__(LAIR_TPB, LAIR_MINBLOCKS)
#else
#define LAIR_LAUNCH_BOUNDS __launch_bounds__(LAIR_TPB)
#endif
// Decide most nonces on element 0 of the final state before computing the
// rest of the last linear layer: at mainnet difficulty the top ~47 bits of
// the hash must be zero, and they live in that element.
#ifndef LAIR_EARLY_REJECT
#define LAIR_EARLY_REJECT 1
#endif
// Derive each nonce's state after the first linear layer from the host's
// precomputed base state plus one scalar times column 7 of the external
// matrix, instead of absorbing and recomputing the layer per nonce. Falls
// back to the full path for the nonces past a carry out of the low limb.
#ifndef LAIR_NONCE_DIR
#define LAIR_NONCE_DIR 1
#endif
#if LAIR_NONCE_DIR && !(LAIR_LAZY_ADD && LAIR_FOLD_RC && LAIR_FUSED_MUL)
#error "LAIR_NONCE_DIR needs LAIR_LAZY_ADD, LAIR_FOLD_RC and LAIR_FUSED_MUL"
#endif
// Deferred-carry accumulator: the value is lo + hi * 2^64 with hi small. Adds
// cost an add-with-carry instead of two compare-and-fold steps; the folds are
// paid once per output when the accumulator is reduced. Sound as long as
// hi < 2^32, which a linear layer's sums (at most a few dozen terms) satisfy.
struct Acc {
u64 lo;
u32 hi;
};
__device__ __forceinline__ Acc acc_of(u64 a) {
Acc r; r.lo = a; r.hi = 0u; return r;
}
#if LAIR_PTX_ACC && LAIR_FUSED_MUL
// 32-bit limb chains. On sm_120 ptxas turns `add.cc.u64` + `addc.u32` into
// two IADD.64 (one for the carry, one for the sum) and a select, five
// instructions per add; the 32-bit chain is IADD, IADD.X and one IADD3.X
// that ptxas merges with the neighbouring carry, under three.
__device__ __forceinline__ Acc acc_add(Acc a, u64 b) {
u64 s; u32 c;
asm("{\n\t"
".reg .b32 al, ah, bl, bh;\n\t"
"mov.b64 {al, ah}, %2;\n\t"
"mov.b64 {bl, bh}, %3;\n\t"
"add.cc.u32 al, al, bl;\n\t"
"addc.cc.u32 ah, ah, bh;\n\t"
"addc.u32 %1, %4, 0;\n\t"
"mov.b64 %0, {al, ah};\n\t"
"}"
: "=l"(s), "=r"(c) : "l"(a.lo), "l"(b), "r"(a.hi));
a.lo = s;
a.hi = c;
return a;
}
__device__ __forceinline__ Acc acc_add2(Acc a, Acc b) {
u64 s; u32 c;
asm("{\n\t"
".reg .b32 al, ah, bl, bh;\n\t"
"mov.b64 {al, ah}, %2;\n\t"
"mov.b64 {bl, bh}, %3;\n\t"
"add.cc.u32 al, al, bl;\n\t"
"addc.cc.u32 ah, ah, bh;\n\t"
"addc.u32 %1, %4, %5;\n\t"
"mov.b64 %0, {al, ah};\n\t"
"}"
: "=l"(s), "=r"(c) : "l"(a.lo), "l"(b.lo), "r"(a.hi), "r"(b.hi));
a.lo = s;
a.hi = c;
return a;
}
#elif LAIR_PTX_ACC
__device__ __forceinline__ Acc acc_add(Acc a, u64 b) {
u64 s; u32 c;
asm("add.cc.u64 %0, %2, %3;\n\taddc.u32 %1, 0, 0;" : "=l"(s), "=r"(c) : "l"(a.lo), "l"(b));
a.lo = s;
a.hi += c;
return a;
}
__device__ __forceinline__ Acc acc_add2(Acc a, Acc b) {
u64 s; u32 c;
asm("add.cc.u64 %0, %2, %3;\n\taddc.u32 %1, %4, %5;" : "=l"(s), "=r"(c) : "l"(a.lo), "l"(b.lo), "r"(a.hi), "r"(b.hi));
a.lo = s;
a.hi = c;
return a;
}
#else
__device__ __forceinline__ Acc acc_add(Acc a, u64 b) {
u64 s = a.lo + b;
a.hi += (s < a.lo) ? 1u : 0u;
a.lo = s;
return a;
}
__device__ __forceinline__ Acc acc_add2(Acc a, Acc b) {
u64 s = a.lo + b.lo;
a.hi += b.hi + ((s < a.lo) ? 1u : 0u);
a.lo = s;
return a;
}
#endif
#if LAIR_FUSED_MUL
// lo + hi * 2^64 = lo + hi * EPS (mod P), one fused reduction. Exact: with
// no 2^96 term there is no borrow to drop.
__device__ __forceinline__ u64 acc_reduce(Acc a) {
u64 r;
u32 e = LAIR_EPS32;
asm("{\n\t"
".reg .b32 ll, lh, c;\n\t"
"mov.b64 {ll, lh}, %1;\n\t"
"mad.lo.cc.u32 ll, %2, %3, ll;\n\t"
"madc.hi.cc.u32 lh, %2, %3, lh;\n\t"
"addc.u32 c, 0, 0;\n\t"
"addc.u32 lh, lh, 0;\n\t"
"sub.cc.u32 ll, ll, c;\n\t"
"subc.u32 lh, lh, 0;\n\t"
"mov.b64 %0, {ll, lh};\n\t"
"}"
: "=l"(r) : "l"(a.lo), "r"(a.hi), "r"(e));
return r;
}
// a * b + c mod P. The addend rides in the two `mad.wide` partial products
// (a0*b0 + c_lo and a1*b0 + c_hi both fit in 64 bits), so it costs nothing.
__device__ __forceinline__ u64 gf_mul_add(u64 a, u64 b, u64 c) {
u64 r;
u32 e = LAIR_EPS32;
u64 c0 = c & EPS, c1 = c >> 32;
asm("{\n\t"
".reg .b32 a0, a1, b0, b1, p0l, p0h, p3l, p3h, m0, m1, cw, ll, lh, hl, hh, c;\n\t"
".reg .b64 p0, m, m2, p3;\n\t"
"mov.b64 {a0, a1}, %1;\n\t"
"mov.b64 {b0, b1}, %2;\n\t"
"mad.wide.u32 p0, a0, b0, %3;\n\t"
"mad.wide.u32 m, a1, b0, %4;\n\t"
"mul.wide.u32 m2, a0, b1;\n\t"
"mul.wide.u32 p3, a1, b1;\n\t"
"mov.b64 {p0l, p0h}, p0;\n\t"
"mov.b64 {p3l, p3h}, p3;\n\t"
"add.cc.u64 m, m, m2;\n\t"
"addc.u32 cw, p3h, 0;\n\t"
"mov.b64 {m0, m1}, m;\n\t"
"add.cc.u32 lh, p0h, m0;\n\t"
"addc.cc.u32 hl, p3l, m1;\n\t"
"addc.u32 hh, cw, 0;\n\t"
"mov.b32 ll, p0l;\n\t"
LAIR_REDUCE_PTX("%5")
"mov.b64 %0, {ll, lh};\n\t"
"}"
: "=l"(r) : "l"(a), "l"(b), "l"(c0), "l"(c1), "r"(e));
return r;
}
// a * b + (c.lo + c.hi * 2^64) mod P: reduce the addend first (4 ops), then
// fold it into the product for free.
__device__ __forceinline__ u64 gf_mul_add_acc(u64 a, u64 b, Acc c) {
return gf_mul_add(a, b, acc_reduce(c));
}
#else
// lo + hi * 2^64 = lo + hi * EPS (mod P); hi * EPS < 2^64, so this is one
// lazy gf_add.
__device__ __forceinline__ u64 acc_reduce(Acc a) {
return gf_add(a.lo, (u64)a.hi * EPS);
}
// a * b + c mod P: the addend is folded into the 128-bit product before the
// single reduction, saving a gf_add per term.
__device__ __forceinline__ u64 gf_mul_add(u64 a, u64 b, u64 c) {
u64 lo = a * b;
u64 hi = __umul64hi(a, b);
u64 s = lo + c;
hi += (s < lo) ? 1ull : 0ull;
return gf_reduce(s, hi);
}
// a * b + (c.lo + c.hi * 2^64) mod P, for a deferred-carry addend.
__device__ __forceinline__ u64 gf_mul_add_acc(u64 a, u64 b, Acc c) {
u64 lo = a * b;
u64 hi = __umul64hi(a, b);
u64 s = lo + c.lo;
hi += ((s < lo) ? 1ull : 0ull) + (u64)c.hi;
return gf_reduce(s, hi);
}
#endif // LAIR_FUSED_MUL
#if LAIR_LAZY_ADD
// External linear layer with deferred carries: every output is a sum of a
// handful of inputs, reduced once.
// `rc` is the next round's constant row to fold into the outputs, or nullptr.
__device__ __forceinline__ void ext_layer_rc(u64 st[12], const u64* rc) {
Acc acc[12];
#pragma unroll
for (int chunk = 0; chunk < 3; chunk++) {
int o = chunk * 4;
u64 x0 = st[o], x1 = st[o + 1], x2 = st[o + 2], x3 = st[o + 3];
Acc t01 = acc_add(acc_of(x0), x1);
Acc t23 = acc_add(acc_of(x2), x3);
Acc t0123 = acc_add2(t01, t23);
Acc t01123 = acc_add(t0123, x1);
Acc t01233 = acc_add(t0123, x3);
acc[o + 3] = acc_add(acc_add(t01233, x0), x0);
acc[o + 1] = acc_add(acc_add(t01123, x2), x2);
acc[o] = acc_add2(t01123, t01);
acc[o + 2] = acc_add2(t01233, t23);
}
Acc sums[4];
#pragma unroll
for (int k = 0; k < 4; k++) {
sums[k] = acc_add2(acc_add2(acc[k], acc[k + 4]), acc[k + 8]);
}
#pragma unroll
for (int i = 0; i < 12; i++) {
Acc o = acc_add2(acc[i], sums[i & 3]);
if (rc != nullptr) o = acc_add(o, rc[i]);
st[i] = acc_reduce(o);
}
}
__device__ __forceinline__ void ext_layer(u64 st[12]) { ext_layer_rc(st, nullptr); }
// Output 0 of the external linear layer alone, inputs untouched: the chunk
// sums it needs (about a tenth of the full layer).
__device__ __forceinline__ u64 ext_layer_out0(const u64 st[12]) {
Acc a[3];
#pragma unroll
for (int chunk = 0; chunk < 3; chunk++) {
int o = chunk * 4;
Acc t01 = acc_add(acc_of(st[o]), st[o + 1]);
Acc t23 = acc_add(acc_of(st[o + 2]), st[o + 3]);
Acc t01123 = acc_add(acc_add2(t01, t23), st[o + 1]);
a[chunk] = acc_add2(t01123, t01);
}
Acc sum0 = acc_add2(acc_add2(a[0], a[1]), a[2]);
return acc_reduce(acc_add2(a[0], sum0));
}
// Internal linear layer: one deferred sum, folded into each diagonal multiply.
// `rc0` is the next internal round's constant for element 0, folded into that
// element's multiply-add; `rc_row` a full row (the first terminal round's).
__device__ __forceinline__ void int_layer_rc(u64 st[12], u64 rc0, bool has_rc0, const u64* rc_row) {
Acc s = acc_of(st[0]);
#pragma unroll
for (int i = 1; i < 12; i++) s = acc_add(s, st[i]);
u64 sum = acc_reduce(s);
if (rc_row != nullptr) {
// The constant joins the unreduced sum: one more limb add, then the
// same single reduction.
#pragma unroll
for (int i = 0; i < 12; i++) st[i] = gf_mul_add_acc(st[i], MDS_DIAG[i], acc_add(s, rc_row[i]));
} else {
if (has_rc0) {
st[0] = gf_mul_add_acc(st[0], MDS_DIAG[0], acc_add(s, rc0));
} else {
st[0] = gf_mul_add(st[0], MDS_DIAG[0], sum);
}
#pragma unroll
for (int i = 1; i < 12; i++) st[i] = gf_mul_add(st[i], MDS_DIAG[i], sum);
}
}
__device__ __forceinline__ void int_layer(u64 st[12]) { int_layer_rc(st, 0ull, false, nullptr); }
#else
// External linear layer: 4x4 MDS on each chunk, then circulant sums.
__device__ __forceinline__ void ext_layer(u64 st[12]) {
#pragma unroll
for (int chunk = 0; chunk < 3; chunk++) {
int o = chunk * 4;
u64 x0 = st[o], x1 = st[o + 1], x2 = st[o + 2], x3 = st[o + 3];
u64 t01 = gf_add(x0, x1);
u64 t23 = gf_add(x2, x3);
u64 t0123 = gf_add(t01, t23);
u64 t01123 = gf_add(t0123, x1);
u64 t01233 = gf_add(t0123, x3);
st[o + 3] = gf_add(t01233, gf_add(x0, x0));
st[o + 1] = gf_add(t01123, gf_add(x2, x2));
st[o] = gf_add(t01123, t01);
st[o + 2] = gf_add(t01233, t23);
}
u64 sums[4];
#pragma unroll
for (int k = 0; k < 4; k++) {
sums[k] = gf_add(gf_add(st[k], st[k + 4]), st[k + 8]);
}
#pragma unroll
for (int i = 0; i < 12; i++) {
st[i] = gf_add(st[i], sums[i & 3]);
}
}
__device__ __forceinline__ u64 ext_layer_out0(const u64 st[12]) {
u64 a[3];
#pragma unroll
for (int chunk = 0; chunk < 3; chunk++) {
int o = chunk * 4;
u64 t01 = gf_add(st[o], st[o + 1]);
u64 t23 = gf_add(st[o + 2], st[o + 3]);
a[chunk] = gf_add(gf_add(gf_add(t01, t23), st[o + 1]), t01);
}
return gf_add(a[0], gf_add(gf_add(a[0], a[1]), a[2]));
}
// Internal linear layer: diagonal matrix plus full sum.
__device__ __forceinline__ void int_layer(u64 st[12]) {
u64 sum = st[0];
#pragma unroll
for (int i = 1; i < 12; i++) sum = gf_add(sum, st[i]);
#pragma unroll
for (int i = 0; i < 12; i++) st[i] = gf_add(gf_mul(st[i], MDS_DIAG[i]), sum);
}
#endif
// `skip_last_ext`: leave the state before the final external linear layer,
// for the caller to finish with ext_layer_out0 / ext_layer.
#if LAIR_NOINLINE_PERMUTE
// One copy of the (fully unrolled) permutation instead of three inlined ones:
// 17k instructions instead of 51k, for instruction-cache pressure.
__device__ __noinline__ void permute(u64 st[12], bool skip_last_ext = false, bool skip_first_ext = false) {
#else
__device__ __forceinline__ void permute(u64 st[12], bool skip_last_ext = false, bool skip_first_ext = false) {
#endif
#if LAIR_LAZY_ADD && LAIR_FOLD_RC
// Each linear layer folds the constant of the round that follows it.
// `skip_first_ext`: the caller supplies the state after this layer.
if (!skip_first_ext) ext_layer_rc(st, RC_INITIAL[0]);
#pragma unroll
for (int r = 0; r < N_EXTERNAL_HALF; r++) {
#pragma unroll
for (int i = 0; i < 12; i++) st[i] = gf_sbox(st[i]);
if (r + 1 < N_EXTERNAL_HALF) {
ext_layer_rc(st, RC_INITIAL[r + 1]);
} else {
ext_layer(st);
st[0] = gf_add(st[0], RC_INTERNAL[0]);
}
}
LAIR_UNROLL(LAIR_INT_UNROLL)
for (int r = 0; r < N_INTERNAL; r++) {
st[0] = gf_sbox(st[0]);
if (r + 1 < N_INTERNAL) {
int_layer_rc(st, RC_INTERNAL[r + 1], true, nullptr);
} else {
int_layer_rc(st, 0ull, false, RC_TERMINAL[0]);
}
}
#pragma unroll
for (int r = 0; r < N_EXTERNAL_HALF; r++) {
#pragma unroll
for (int i = 0; i < 12; i++) st[i] = gf_sbox(st[i]);
if (r + 1 < N_EXTERNAL_HALF) {
ext_layer_rc(st, RC_TERMINAL[r + 1]);
} else if (!skip_last_ext) {
ext_layer(st);
}
}
#else
ext_layer(st);
#pragma unroll
for (int r = 0; r < N_EXTERNAL_HALF; r++) {
#pragma unroll
for (int i = 0; i < 12; i++) st[i] = gf_sbox(gf_add(st[i], RC_INITIAL[r][i]));
ext_layer(st);
}
LAIR_UNROLL(LAIR_INT_UNROLL)
for (int r = 0; r < N_INTERNAL; r++) {
st[0] = gf_sbox(gf_add(st[0], RC_INTERNAL[r]));
int_layer(st);
}
#pragma unroll
for (int r = 0; r < N_EXTERNAL_HALF; r++) {
#pragma unroll
for (int i = 0; i < 12; i++) st[i] = gf_sbox(gf_add(st[i], RC_TERMINAL[r][i]));
if (r + 1 < N_EXTERNAL_HALF || !skip_last_ext) ext_layer(st);
}
#endif
}
__device__ __forceinline__ u32 bswap32(u32 v) {
return __byte_perm(v, 0, 0x0123);
}
// Bindings mirror the WGSL kernel:
// results [flag, nonce(16 u32 LE), hash(16 u32 LE)] = 33 u32
// midstate 12 felts as u64
// start_nonce 16 u32 LE limbs
// target 16 u32 LE limbs
// lair: the launch-uniform inputs ride in the parameter bank instead of being
// copied into per-thread registers (quantus/miner#3). Keeping midstate (24
// registers), target (16) and the nonce base (16) live across the whole nonce
// loop cost 56 registers of residency that the permutation's own working set
// needs more: ptxas spilled 104 bytes per thread on sm_120 and 44 on sm_86/89.
// Parameters are constant memory -- broadcast and cached, no register cost --
// and they arrive with the launch, so the host no longer copies midstate,
// start_nonce or target to the device once per batch.
struct MiningUniforms {
u64 midstate[12];
u32 start_nonce[16];
u32 target[16];
// State after the first external layer + its round constant for the
// batch's first nonce (host: first_layer_after_absorb).
u64 layer0_base[12];
};
static_assert(sizeof(MiningUniforms) == 320, "MiningUniforms must match the host layout");
#if LAIR_NONCE_DIR
// Column 7 of the external matrix circ(2*M4, M4, M4): what the layer adds per
// unit of element 7, which is where the low nonce limb is absorbed. Checked by
// engine-cuda's `column_seven_of_external_matrix` test.
__device__ __constant__ u64 LAIR_DIR7[12] = {1, 1, 3, 2, 2, 2, 6, 4, 1, 1, 3, 2};
#endif
extern "C" __global__ void LAIR_LAUNCH_BOUNDS
mining_main(u32* __restrict__ results,
const MiningUniforms uni,
u32 total_threads,
u32 nonces_per_thread,
u32 total_nonces)
{
if (*((volatile u32*)results) != 0u) return;
u32 tid = blockIdx.x * blockDim.x + threadIdx.x;
if (tid >= total_threads) return;
u32 base_index = tid * nonces_per_thread;
for (u32 j = 0; j < nonces_per_thread; j++) {
u32 logical_index = base_index + j;
if (logical_index >= total_nonces) break;
if (j > 0u && *((volatile u32*)results) != 0u) return;
// Low 256 bits only; the host guarantees no carry into limbs 8..15, so
// the high half is never materialised here -- on a hit it is written
// straight from the parameter bank.
u32 current_nonce[8];
u32 val0 = uni.start_nonce[0];
u32 sum0 = val0 + logical_index;
current_nonce[0] = sum0;
u32 carry = sum0 < val0 ? 1u : 0u;
#pragma unroll
for (int i = 1; i < 8; i++) {
u32 val = uni.start_nonce[i];
u32 sum = val + carry;
current_nonce[i] = sum;
carry = sum < val ? 1u : 0u;
}
// Resume the sponge from the midstate: absorb the low nonce half,
// pad, squeeze twice (3 permutations instead of 5; the second squeeze
// only for candidates).
u64 st[12];
#if LAIR_NONCE_DIR
if (current_nonce[1] == uni.start_nonce[1]) {
// Only the low limb differs from the batch's first nonce, so only
// element 7 of the absorbed state does, by d; the layer is linear.
u64 a = (u64)bswap32(current_nonce[0]);
u64 b = (u64)bswap32(uni.start_nonce[0]);
u64 d = a >= b ? a - b : a + (P64 - b);
#pragma unroll
for (int i = 0; i < 12; i++) st[i] = gf_mul_add(d, LAIR_DIR7[i], uni.layer0_base[i]);
} else {
// Past a carry out of the low limb: absorb and apply the layer.
#pragma unroll
for (int i = 0; i < 12; i++) st[i] = uni.midstate[i];
#pragma unroll
for (int i = 0; i < 8; i++) st[i] = gf_add(st[i], (u64)bswap32(current_nonce[7 - i]));
ext_layer_rc(st, RC_INITIAL[0]);
}
permute(st, false, true);
#else
#pragma unroll
for (int i = 0; i < 12; i++) st[i] = uni.midstate[i];
#pragma unroll
for (int i = 0; i < 8; i++) st[i] = gf_add(st[i], (u64)bswap32(current_nonce[7 - i]));
permute(st);
#endif
st[0] = gf_add(st[0], 1ull);
st[1] = gf_add(st[1], 1ull);
#if LAIR_EARLY_REJECT
// Element 0 of the final state is the hash's top 64 bits. Compute it
// alone, and only nonces that do not already exceed the target there
// pay for the other eleven outputs of the last linear layer.
permute(st, true);
{
u64 c0 = gf_canon(ext_layer_out0(st));
u32 h0 = bswap32((u32)(c0 & EPS));
u32 t0 = uni.target[15];
if (h0 > t0) continue;
if (h0 == t0 && bswap32((u32)(c0 >> 32)) > uni.target[14]) continue;
}
ext_layer(st);
#else
permute(st);
#endif
// First squeeze: most significant 256 bits of the hash.
u32 first[8];
#pragma unroll
for (int i = 0; i < 4; i++) {
u64 c = gf_canon(st[i]);
first[2 * i] = (u32)(c & EPS);
first[2 * i + 1] = (u32)(c >> 32);
}
u32 cmp = 0u; // 0 equal so far, 1 above target, 2 below target
#pragma unroll
for (int i = 0; i < 8; i++) {
u32 h = bswap32(first[i]);
u32 t = uni.target[15 - i];
if (h != t) { cmp = h > t ? 1u : 2u; break; }
}
if (cmp == 1u) continue;
u32 hash_le[16];
#pragma unroll
for (int i = 0; i < 8; i++) hash_le[15 - i] = bswap32(first[i]);
permute(st);
#pragma unroll
for (int i = 0; i < 4; i++) {
u64 c = gf_canon(st[i]);
hash_le[7 - 2 * i] = bswap32((u32)(c & EPS));
hash_le[6 - 2 * i] = bswap32((u32)(c >> 32));
}
bool below = (cmp == 2u);
if (!below) {
#pragma unroll
for (int i = 0; i < 8; i++) {
u32 h = hash_le[7 - i];
u32 t = uni.target[7 - i];
if (h != t) { below = h < t; break; }
}
}
if (below) {
if (atomicExch(&results[0], 1u) == 0u) {
#pragma unroll
for (int i = 0; i < 8; i++) results[1 + i] = current_nonce[i];
#pragma unroll
for (int i = 8; i < 16; i++) results[1 + i] = uni.start_nonce[i];
#pragma unroll
for (int i = 0; i < 16; i++) results[17 + i] = hash_le[i];
__threadfence();
}
return;
}
}
}

View File

@@ -0,0 +1,630 @@
#![deny(rust_2018_idioms)]
//! lair: native CUDA mining engine behind `MinerEngine` (quantus/miner#3).
//!
//! Same host contract and batch loop as `engine-gpu` (midstate per batch, no
//! carry into the high nonce half, cancellation between batches, thread-local
//! worker-to-device assignment), with the kernel in `kernels/mining.cu` built
//! into a fat binary by `build.rs`. Nothing in `engine-gpu` is touched; the
//! service picks this engine when the binary carries a kernel and a CUDA
//! driver is present, and falls back to wgpu otherwise.
use cudarc::driver::{
CudaContext, CudaFunction, CudaSlice, CudaStream, DeviceRepr, DriverError, LaunchConfig,
PushKernelArg,
};
use cudarc::nvrtc::Ptx;
use engine_cpu::{CancelCheck, Candidate, EngineStatus, FoundOrigin, MinerEngine, Range};
use pow_core::{format_hashrate, format_u512, JobContext};
use primitive_types::U512;
use std::cell::RefCell;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Instant;
/// The fat binary produced by build.rs; empty when nvcc was not available.
const FATBIN: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/mining.fatbin"));
/// SM list the fat binary carries, for `--version` and the config metric.
pub const CUDA_ARCHS: &str = env!("MINER_CUDA_ARCHS");
const KERNEL_ID: &str = "cuda";
/// Threads per block. Must match the kernel's `__launch_bounds__` (LAIR_TPB,
/// default 256); override for experiments with MINER_CUDA_THREADS_PER_BLOCK
/// on a kernel compiled with the same value.
const THREADS_PER_BLOCK_DEFAULT: u32 = 256;
fn threads_per_block() -> u32 {
std::env::var("MINER_CUDA_THREADS_PER_BLOCK")
.ok()
.and_then(|v| v.parse().ok())
.filter(|v: &u32| *v > 0 && v.is_multiple_of(32))
.unwrap_or(THREADS_PER_BLOCK_DEFAULT)
}
/// lair: size every batch to whole grids. The CLI default batch (1M nonces)
/// fills a 5090 about eleven waves deep with a half-empty tail wave and pays
/// the per-launch gap every 1M hashes. Rounding the batch up to a multiple of
/// the grid (SMs x threads per SM, one nonce per thread) measured +1.6% on
/// beast, and two grids per batch (each thread loops over two nonces) a
/// further +0.4% there and +0.5% on the 4090, neutral on the 3060
/// (quantus/miner#3, 2026-09-13). A batch is then ~10 ms on every card, so a
/// job switch discards about 0.1% of a block interval. Overrides:
/// MINER_CUDA_BATCH_ALIGN=0 for the CLI batch as given, MINER_CUDA_BATCH_WAVES
/// for the minimum number of grids per batch.
const BATCH_WAVES_DEFAULT: u32 = 2;
fn batch_align() -> bool {
std::env::var("MINER_CUDA_BATCH_ALIGN")
.map(|v| v != "0")
.unwrap_or(true)
}
fn batch_waves() -> u32 {
std::env::var("MINER_CUDA_BATCH_WAVES")
.ok()
.and_then(|v| v.parse().ok())
.filter(|v: &u32| *v > 0)
.unwrap_or(BATCH_WAVES_DEFAULT)
}
/// Threads per SM the grid is sized for (override: MINER_CUDA_THREADS_PER_SM).
/// Sets how many nonces each thread loops over for a given batch; tuned with #2.
// Measured on the 4090 (#3): 8192 caps the grid at ~1M threads, so batches
// above 1M loop nonces per thread and lose up to 4%; 32768 keeps one nonce per
// thread up to 4M and is flat across batch sizes at ~305 MH/s.
const THREADS_PER_SM_DEFAULT: u32 = 32768;
fn threads_per_sm() -> u32 {
std::env::var("MINER_CUDA_THREADS_PER_SM")
.ok()
.and_then(|v| v.parse().ok())
.filter(|v: &u32| *v > 0)
.unwrap_or(THREADS_PER_SM_DEFAULT)
}
static ENGINE_ID_COUNTER: AtomicUsize = AtomicUsize::new(0);
thread_local! {
static ASSIGNED_DEVICE: RefCell<Option<(usize, usize)>> = const { RefCell::new(None) };
static WORKER_RESOURCES: RefCell<Option<(usize, WorkerResources)>> = const { RefCell::new(None) };
static DEVICE_LOST: RefCell<Option<usize>> = const { RefCell::new(None) };
}
struct Device {
ctx: Arc<CudaContext>,
func: CudaFunction,
name: String,
sm_count: u32,
threads_per_block: u32,
threads_per_sm: u32,
metrics: metrics::DeviceMetrics,
}
struct WorkerResources {
stream: Arc<CudaStream>,
results: CudaSlice<u32>,
host_results: Vec<u32>,
/// Target limbs for this job context, recomputed when the target changes.
target_u32s: [u32; 16],
/// Target the limbs above were derived from.
target_written: Option<U512>,
}
const RESULTS_LEN: usize = 1 + 16 + 16;
/// lair: the kernel's launch-uniform inputs, passed by value so they land in
/// the parameter bank (constant memory) instead of per-thread registers.
///
/// Layout must match `struct MiningUniforms` in `kernels/mining.cu`. Passing
/// them as parameters rather than device buffers took the kernel from 128
/// registers with 104 bytes of per-thread spill on sm_120 (44 on sm_86/89) to
/// 106 / 80 registers with none, and removed three host-to-device copies per
/// batch.
#[repr(C)]
#[derive(Clone, Copy)]
struct MiningUniforms {
midstate: [u64; 12],
start_nonce: [u32; 16],
target: [u32; 16],
/// lair: the state after the first external linear layer (and its round
/// constant) for the batch's first nonce. That layer is linear, and only
/// the least significant nonce limb changes within a batch, so the kernel
/// derives each nonce's state from this by one scalar times a fixed
/// small-integer column instead of recomputing the layer (quantus/miner#3).
layer0_base: [u64; 12],
}
// SAFETY: `#[repr(C)]` plain-old-data with no padding (12 x u64, 32 x u32, 12 x u64),
// matching the kernel's parameter layout; there is nothing to validate beyond
// the layout, which is what this marker asserts.
unsafe impl DeviceRepr for MiningUniforms {}
// A mismatch here would silently feed the kernel the wrong midstate or target,
// so it is a build error rather than a mining bug. The kernel carries the same
// assertion.
const _: () = assert!(std::mem::size_of::<MiningUniforms>() == 320);
pub struct CudaEngine {
engine_id: usize,
devices: Vec<Arc<Device>>,
device_counter: AtomicUsize,
batch_size: u32,
throttle_ms: u64,
}
impl CudaEngine {
/// Returns an error when the binary carries no kernel, no CUDA driver is
/// present, or no device initialises.
pub fn try_new(batch_size: u32, throttle_ms: u64) -> Result<Self, String> {
if batch_size == 0 {
return Err("batch size must be non-zero".into());
}
if FATBIN.is_empty() {
return Err("CUDA kernel not compiled into this binary (built without nvcc)".into());
}
let count =
CudaContext::device_count().map_err(|e| format!("CUDA driver unavailable: {e:?}"))?;
if count <= 0 {
return Err("no CUDA devices".into());
}
let mut devices = Vec::new();
for ordinal in 0..count as usize {
match Device::init(ordinal) {
Ok(d) => {
log::info!(
target: "cuda_engine",
"CUDA device {ordinal}: {} ({} SMs) using {KERNEL_ID} kernel [sm_{{{}}}]",
d.name,
d.sm_count,
CUDA_ARCHS
);
devices.push(Arc::new(d));
}
Err(e) => {
log::warn!(target: "cuda_engine", "CUDA device {ordinal} failed to initialise: {e:?}; skipping");
}
}
}
if devices.is_empty() {
return Err("no CUDA device could be initialised".into());
}
log::info!(
target: "cuda_engine",
"CUDA engine initialized with {} devices (batch size: {} nonces, throttle: {}ms)",
devices.len(),
batch_size,
throttle_ms
);
Ok(Self {
engine_id: ENGINE_ID_COUNTER.fetch_add(1, Ordering::SeqCst),
devices,
device_counter: AtomicUsize::new(0),
batch_size,
throttle_ms,
})
}
pub fn device_count(&self) -> usize {
self.devices.len()
}
/// Drop the calling thread's device resources (call on worker exit).
pub fn clear_worker_resources() {
WORKER_RESOURCES.with(|r| *r.borrow_mut() = None);
ASSIGNED_DEVICE.with(|a| *a.borrow_mut() = None);
}
}
impl Device {
fn init(ordinal: usize) -> Result<Self, DriverError> {
let ctx = CudaContext::new(ordinal)?;
let name = ctx.name()?;
let sm_count = ctx.attribute(
cudarc::driver::sys::CUdevice_attribute::CU_DEVICE_ATTRIBUTE_MULTIPROCESSOR_COUNT,
)? as u32;
let module = ctx.load_module(Ptx::from_binary(FATBIN.to_vec()))?;
let func = module.load_function("mining_main")?;
Ok(Self {
ctx,
func,
name,
sm_count,
threads_per_sm: threads_per_sm(),
threads_per_block: threads_per_block(),
metrics: metrics::DeviceMetrics::new(ordinal, KERNEL_ID),
})
}
fn create_resources(&self) -> Result<WorkerResources, DriverError> {
let stream = self.ctx.new_stream()?;
Ok(WorkerResources {
results: stream.alloc_zeros::<u32>(RESULTS_LEN)?,
host_results: vec![0u32; RESULTS_LEN],
target_u32s: [0u32; 16],
target_written: None,
stream,
})
}
}
enum BatchResult {
Found {
candidate: Candidate,
hash_count: u64,
},
NotFound {
hash_count: u64,
},
DeviceLost,
}
impl MinerEngine for CudaEngine {
fn name(&self) -> &'static str {
"gpu-cuda"
}
fn prepare_context(&self, header_hash: [u8; 32], difficulty: U512) -> JobContext {
JobContext::new(header_hash, difficulty)
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn search_range(
&self,
ctx: &JobContext,
range: Range,
cancel: &dyn CancelCheck,
) -> EngineStatus {
if DEVICE_LOST.with(|l| *l.borrow() == Some(self.engine_id)) {
return EngineStatus::DeviceLost { hash_count: 0 };
}
if range.start > range.end {
return EngineStatus::Exhausted { hash_count: 0 };
}
if cancel.is_cancelled() {
return EngineStatus::Cancelled { hash_count: 0 };
}
let device_index = ASSIGNED_DEVICE.with(|a| {
let mut a = a.borrow_mut();
match *a {
Some((id, idx)) if id == self.engine_id => idx,
_ => {
let idx = if self.devices.len() == 1 {
0
} else {
self.device_counter.fetch_add(1, Ordering::SeqCst) % self.devices.len()
};
*a = Some((self.engine_id, idx));
log::info!(target: "cuda_engine", "Worker thread assigned to CUDA device {idx} (of {} total devices)", self.devices.len());
idx
}
}
});
let dev = &self.devices[device_index];
WORKER_RESOURCES.with(|cell| {
let mut slot = cell.borrow_mut();
let need_new = !matches!(&*slot, Some((id, _)) if *id == self.engine_id);
if need_new {
match dev.create_resources() {
Ok(r) => *slot = Some((self.engine_id, r)),
Err(e) => {
log::error!(target: "cuda_engine", "CUDA device {device_index} resource allocation failed: {e:?}");
DEVICE_LOST.with(|l| *l.borrow_mut() = Some(self.engine_id));
return EngineStatus::DeviceLost { hash_count: 0 };
}
}
}
let (_, res) = slot.as_mut().expect("resources present");
self.search_on(dev, device_index, res, ctx, range, cancel)
})
}
}
impl CudaEngine {
fn search_on(
&self,
dev: &Device,
device_index: usize,
res: &mut WorkerResources,
ctx: &JobContext,
range: Range,
cancel: &dyn CancelCheck,
) -> EngineStatus {
// Target is per job; convert it to limbs once per context. It reaches
// the kernel in the parameter bank, so there is nothing to upload.
if res.target_written != Some(ctx.target) {
let target_bytes = ctx.target.to_little_endian();
for i in 0..16 {
res.target_u32s[i] =
u32::from_le_bytes(target_bytes[i * 4..(i + 1) * 4].try_into().unwrap());
}
res.target_written = Some(ctx.target);
}
let search_start = Instant::now();
let mut total_hashes: u64 = 0;
let mut current_start = range.start;
let mut batch_num = 0u64;
let mut last_batch_hashes: u64 = 0;
log::info!(
target: "cuda_engine",
"CUDA {} search started: range {}..{}, batch size: {} nonces",
device_index,
format_u512(range.start),
format_u512(range.end),
self.batch_size
);
while current_start <= range.end {
if cancel.is_cancelled() {
dev.metrics.record_stale_hashes(last_batch_hashes);
let elapsed = search_start.elapsed();
log::info!(
target: "cuda_engine",
"CUDA {} cancelled before batch {} ({} total hashes in {:.2}s, {})",
device_index,
batch_num,
total_hashes,
elapsed.as_secs_f64(),
format_hashrate(total_hashes as f64 / elapsed.as_secs_f64())
);
return EngineStatus::Cancelled {
hash_count: total_hashes,
};
}
// Clamp so nonce increments never carry into the high 256 bits,
// which the midstate precompute relies on.
let remaining = range
.end
.saturating_sub(current_start)
.saturating_add(U512::one());
let headroom =
(U512::one() << 256) - (current_start & ((U512::one() << 256) - U512::one()));
let cap = remaining.min(headroom);
let want: u32 = if batch_align() {
let grid = (dev.sm_count * dev.threads_per_sm).max(1);
let waves = self.batch_size.div_ceil(grid).max(batch_waves());
waves.saturating_mul(grid)
} else {
self.batch_size
};
let this_batch: u32 = if cap > U512::from(want) {
want
} else {
cap.low_u32()
};
match self.run_single_batch(dev, res, ctx, current_start, this_batch) {
BatchResult::Found {
candidate,
hash_count,
} => {
total_hashes += hash_count;
return EngineStatus::Found {
candidate,
hash_count: total_hashes,
origin: FoundOrigin::GpuG1,
};
}
BatchResult::NotFound { hash_count } => {
total_hashes += hash_count;
last_batch_hashes = hash_count;
}
BatchResult::DeviceLost => return self.device_lost(dev, total_hashes),
}
current_start = current_start.saturating_add(U512::from(this_batch));
batch_num += 1;
if self.throttle_ms > 0 && current_start <= range.end {
let step = std::time::Duration::from_millis((self.throttle_ms / 10).max(1));
let mut remaining = std::time::Duration::from_millis(self.throttle_ms);
while remaining > std::time::Duration::ZERO {
if cancel.is_cancelled() {
return EngineStatus::Cancelled {
hash_count: total_hashes,
};
}
let s = remaining.min(step);
std::thread::sleep(s);
remaining = remaining.saturating_sub(s);
}
}
}
let elapsed = search_start.elapsed();
log::info!(
target: "cuda_engine",
"CUDA {} search exhausted: {} hashes in {} batches ({:.2}s, {})",
device_index,
total_hashes,
batch_num,
elapsed.as_secs_f64(),
format_hashrate(total_hashes as f64 / elapsed.as_secs_f64())
);
EngineStatus::Exhausted {
hash_count: total_hashes,
}
}
fn device_lost(&self, dev: &Device, hash_count: u64) -> EngineStatus {
dev.metrics.record_device_lost();
DEVICE_LOST.with(|l| *l.borrow_mut() = Some(self.engine_id));
log::error!(target: "cuda_engine", "CUDA device lost or unresponsive - stopping worker");
EngineStatus::DeviceLost { hash_count }
}
fn run_single_batch(
&self,
dev: &Device,
res: &mut WorkerResources,
ctx: &JobContext,
batch_start: U512,
batch_size: u32,
) -> BatchResult {
let batch_start_at = Instant::now();
// Grid: enough threads to fill the card several waves deep, then loop
// the remainder per thread.
let max_threads = (dev.sm_count * dev.threads_per_sm) as u64;
let logical_threads = (batch_size as u64).min(max_threads).max(1);
let tpb = dev.threads_per_block;
let num_blocks = ((logical_threads as u32).div_ceil(tpb)).max(1);
let total_threads = (num_blocks * tpb) as u64;
let nonces_per_thread = ((batch_size as u64).div_ceil(total_threads)).max(1) as u32;
let total_threads_u32 = total_threads as u32;
let start_nonce_bytes = batch_start.to_little_endian();
let mut start_u32s = [0u32; 16];
for i in 0..16 {
start_u32s[i] =
u32::from_le_bytes(start_nonce_bytes[i * 4..(i + 1) * 4].try_into().unwrap());
}
let nonce_be = batch_start.to_big_endian();
let midstate = pow_core::mining_midstate(ctx.header, nonce_be[..32].try_into().unwrap());
let layer0_base = first_layer_after_absorb(&midstate, &start_u32s[..8]);
let uni = MiningUniforms {
midstate,
start_nonce: start_u32s,
target: res.target_u32s,
layer0_base,
};
let stream = res.stream.clone();
let r: Result<(), DriverError> = (|| {
stream.memset_zeros(&mut res.results)?;
let cfg = LaunchConfig {
grid_dim: (num_blocks, 1, 1),
block_dim: (tpb, 1, 1),
shared_mem_bytes: 0,
};
let mut launch = stream.launch_builder(&dev.func);
launch
.arg(&mut res.results)
.arg(&uni)
.arg(&total_threads_u32)
.arg(&nonces_per_thread)
.arg(&batch_size);
// SAFETY: the kernel signature matches the argument list above and
// every pointer is a device buffer of at least the size the kernel reads.
unsafe { launch.launch(cfg) }?;
Ok(())
})();
if let Err(e) = r {
log::error!(target: "cuda_engine", "CUDA batch submit failed: {e:?}");
return BatchResult::DeviceLost;
}
let submitted_at = Instant::now();
if let Err(e) = stream
.memcpy_dtoh(&res.results, &mut res.host_results)
.and_then(|_| stream.synchronize())
{
log::error!(target: "cuda_engine", "CUDA batch readback failed: {e:?}");
return BatchResult::DeviceLost;
}
let gpu_time = submitted_at.elapsed();
dev.metrics
.observe_batch(gpu_time, batch_start_at.elapsed() - gpu_time);
let out = &res.host_results;
let dispatched = (total_threads * nonces_per_thread as u64).min(batch_size as u64);
if out[0] != 0 {
let nonce = U512::from_little_endian(&bytemuck_cast(&out[1..17]));
let hash = U512::from_little_endian(&bytemuck_cast(&out[17..33]));
let work = nonce.to_big_endian();
let hashes_computed = if nonce >= batch_start {
let logical_index = (nonce - batch_start).as_u64();
let winning_iteration = logical_index % (nonces_per_thread as u64);
(total_threads * (winning_iteration + 1)).min(dispatched)
} else {
dispatched
};
dev.metrics.record_hashes(hashes_computed);
dev.metrics.record_solution();
return BatchResult::Found {
candidate: Candidate { nonce, work, hash },
hash_count: hashes_computed,
};
}
dev.metrics.record_hashes(dispatched);
BatchResult::NotFound {
hash_count: dispatched,
}
}
}
fn bytemuck_cast(words: &[u32]) -> Vec<u8> {
words.iter().flat_map(|w| w.to_le_bytes()).collect()
}
// lair: host-side twin of the kernel's absorb + first external linear layer,
// in canonical Goldilocks arithmetic. Bit-exact with `ext_layer_rc` in
// kernels/mining.cu modulo p (the kernel keeps lazy representatives).
const GOLDILOCKS_P: u128 = 0xFFFF_FFFF_0000_0001;
fn gf_add(a: u64, b: u64) -> u64 {
((a as u128 + b as u128) % GOLDILOCKS_P) as u64
}
/// State after absorbing the low nonce limbs into the midstate and applying
/// the first external layer plus the first round constant, for the batch's
/// first nonce. `nonce_le` are the low 8 little-endian u32 limbs.
fn first_layer_after_absorb(midstate: &[u64; 12], nonce_le: &[u32]) -> [u64; 12] {
let mut st = *midstate;
for i in 0..8 {
st[i] = gf_add(st[i], nonce_le[7 - i].swap_bytes() as u64);
}
let mut out = [0u64; 12];
for chunk in 0..3 {
let o = chunk * 4;
let (x0, x1, x2, x3) = (st[o], st[o + 1], st[o + 2], st[o + 3]);
let t01 = gf_add(x0, x1);
let t23 = gf_add(x2, x3);
let t0123 = gf_add(t01, t23);
let t01123 = gf_add(t0123, x1);
let t01233 = gf_add(t0123, x3);
out[o + 3] = gf_add(t01233, gf_add(x0, x0));
out[o + 1] = gf_add(t01123, gf_add(x2, x2));
out[o] = gf_add(t01123, t01);
out[o + 2] = gf_add(t01233, t23);
}
let mut sums = [0u64; 4];
for k in 0..4 {
sums[k] = gf_add(gf_add(out[k], out[k + 4]), out[k + 8]);
}
let rc = &qp_poseidon_constants::POSEIDON2_INITIAL_EXTERNAL_CONSTANTS_RAW[0];
for i in 0..12 {
out[i] = gf_add(gf_add(out[i], sums[i & 3]), rc[i]);
}
out
}
#[cfg(test)]
mod layer0_tests {
use super::*;
/// The kernel's fast path assumes column 7 of the external matrix is this
/// vector: applying the layer to the unit vector e7 must reproduce it.
#[test]
fn column_seven_of_external_matrix() {
let zero = [0u64; 12];
// absorb puts bswap(limb 0) into element 7: choose limb 0 so that it becomes 1
let mut nonce = [0u32; 8];
nonce[0] = 1u32.swap_bytes();
let with = first_layer_after_absorb(&zero, &nonce);
let without = first_layer_after_absorb(&zero, &[0u32; 8]);
let rc = &qp_poseidon_constants::POSEIDON2_INITIAL_EXTERNAL_CONSTANTS_RAW[0];
let expect: [u64; 12] = [1, 1, 3, 2, 2, 2, 6, 4, 1, 1, 3, 2];
for i in 0..12 {
assert_eq!(without[i], rc[i]);
let diff = (with[i] as u128 + GOLDILOCKS_P - without[i] as u128) % GOLDILOCKS_P;
assert_eq!(diff as u64, expect[i], "column entry {i}");
}
}
}

View File

@@ -19,6 +19,7 @@ simd-poseidon2 = []
[dependencies]
engine-cpu = { path = "../engine-cpu" }
metrics = { path = "../metrics" } # lair: per-device metrics (quantus/miner#9)
pow-core = { path = "../pow-core" }
primitive-types = { workspace = true }
log = { workspace = true }

View File

@@ -6,6 +6,14 @@ use primitive_types::U512;
use rand::RngCore;
use std::sync::atomic::AtomicBool;
/// Drop thread-local wgpu buffers before `GpuEngine` is dropped. Criterion
/// creates a fresh engine per group; without this, TLS buffers outlive the
/// device and the next group panics (`Buffer[…] does not exist`).
fn teardown_gpu(engine: GpuEngine) {
GpuEngine::clear_worker_resources();
drop(engine);
}
fn bench_cpu_vs_gpu_small(c: &mut Criterion) {
let cpu_engine = FastCpuEngine::new(10_000);
let gpu_engine = GpuEngine::try_new(10_000_000, 0, false).expect("Failed to init GPU");
@@ -55,6 +63,7 @@ fn bench_cpu_vs_gpu_small(c: &mut Criterion) {
});
group.finish();
teardown_gpu(gpu_engine);
}
fn bench_cpu_vs_gpu_medium(c: &mut Criterion) {
@@ -106,6 +115,7 @@ fn bench_cpu_vs_gpu_medium(c: &mut Criterion) {
});
group.finish();
teardown_gpu(gpu_engine);
}
fn bench_cpu_vs_gpu_large(c: &mut Criterion) {
@@ -157,6 +167,7 @@ fn bench_cpu_vs_gpu_large(c: &mut Criterion) {
});
group.finish();
teardown_gpu(gpu_engine);
}
fn bench_solution_finding(c: &mut Criterion) {
@@ -208,6 +219,7 @@ fn bench_solution_finding(c: &mut Criterion) {
});
group.finish();
teardown_gpu(gpu_engine);
}
fn bench_throughput_per_second(c: &mut Criterion) {
@@ -259,6 +271,7 @@ fn bench_throughput_per_second(c: &mut Criterion) {
});
group.finish();
teardown_gpu(gpu_engine);
}
fn bench_gpu_batch_efficiency(c: &mut Criterion) {
@@ -335,6 +348,7 @@ fn bench_gpu_batch_efficiency(c: &mut Criterion) {
});
group.finish();
teardown_gpu(gpu_engine);
}
criterion_group!(

View File

@@ -0,0 +1,63 @@
use engine_cpu::{AtomicBoolCancelCheck, EngineStatus, MinerEngine, Range};
use engine_gpu::GpuEngine;
use primitive_types::U512;
use rand::RngCore;
use std::sync::atomic::AtomicBool;
fn main() {
env_logger::init();
let jobs: usize = std::env::args()
.nth(1)
.map(|s| s.parse().expect("job count"))
.unwrap_or(25);
let engine = GpuEngine::try_new(1_000_000, 0, false).expect("GPU init failed");
let cancel_flag = AtomicBool::new(false);
let cancel = AtomicBoolCancelCheck(&cancel_flag);
let mut rng = rand::rng();
let mut found = 0usize;
for job in 0..jobs {
let mut header = [0u8; 32];
rng.fill_bytes(&mut header);
let difficulty = U512::from(100_000u64);
let ctx = engine.prepare_context(header, difficulty);
let start = if job == 0 {
// Cross a 2^256 boundary: the high nonce half changes mid-range,
// exercising the midstate batch clamp.
(U512::from(3u64) << 256) - U512::from(1_000u64)
} else {
let mut start_bytes = [0u8; 64];
rng.fill_bytes(&mut start_bytes);
// Keep clear of the very top so range arithmetic cannot wrap
start_bytes[0] = 0;
U512::from_big_endian(&start_bytes)
};
let range = Range {
start,
end: start + U512::from(10_000_000u64),
};
match engine.search_range(&ctx, range.clone(), &cancel) {
EngineStatus::Found { candidate, .. } => {
let cpu_hash = pow_core::hash_from_nonce(&ctx, candidate.nonce);
assert_eq!(
cpu_hash, candidate.hash,
"job {job}: GPU hash != CPU hash for nonce {}",
candidate.nonce
);
assert!(cpu_hash < ctx.target, "job {job}: hash not below target");
assert!(
candidate.nonce >= range.start && candidate.nonce <= range.end,
"job {job}: nonce outside range"
);
found += 1;
}
EngineStatus::Exhausted { .. } => {}
other => panic!("job {job}: unexpected status {other:?}"),
}
}
assert!(found > 0, "no solutions found across {jobs} jobs");
println!("PARITY OK: {found}/{jobs} jobs found solutions, all verified against CPU");
}

View File

@@ -0,0 +1,30 @@
fn main() {
let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor {
backends: wgpu::Backends::PRIMARY,
..Default::default()
});
for adapter in instance.enumerate_adapters(wgpu::Backends::PRIMARY) {
let info = adapter.get_info();
let features = adapter.features();
println!("{} ({:?}, {:?})", info.name, info.device_type, info.backend);
println!(
" SHADER_INT64: {}",
features.contains(wgpu::Features::SHADER_INT64)
);
println!(
" SUBGROUP: {}",
features.contains(wgpu::Features::SUBGROUP)
);
println!(
" TIMESTAMP_QUERY: {}",
features.contains(wgpu::Features::TIMESTAMP_QUERY)
);
let limits = adapter.limits();
println!(
" max_workgroup_size_x: {}, max_invocations: {}, max_workgroups_per_dim: {}",
limits.max_compute_workgroup_size_x,
limits.max_compute_invocations_per_workgroup,
limits.max_compute_workgroups_per_dimension
);
}
}

View File

@@ -0,0 +1,52 @@
use engine_cpu::{AtomicBoolCancelCheck, MinerEngine, Range};
use engine_gpu::GpuEngine;
use primitive_types::U512;
use std::sync::atomic::AtomicBool;
fn main() {
env_logger::init();
let args: Vec<String> = std::env::args().collect();
let total: u64 = args
.get(1)
.map(|s| s.parse().expect("total nonces"))
.unwrap_or(4_000_000);
let batch: u32 = args
.get(2)
.map(|s| s.parse().expect("batch size"))
.unwrap_or(1_000_000);
let engine = GpuEngine::try_new(batch, 0, false).expect("GPU init failed");
let cancel_flag = AtomicBool::new(false);
let cancel = AtomicBoolCancelCheck(&cancel_flag);
let header = [42u8; 32];
let ctx = engine.prepare_context(header, U512::from(u64::MAX));
let range = Range {
start: U512::from(1u64) << 200,
end: (U512::from(1u64) << 200) + U512::from(total - 1),
};
// Warmup
let warm = Range {
start: range.start,
end: range.start + U512::from(200_000u64),
};
engine.search_range(&ctx, warm, &cancel);
let start = std::time::Instant::now();
let status = engine.search_range(&ctx, range, &cancel);
let elapsed = start.elapsed().as_secs_f64();
let hashes = match status {
engine_cpu::EngineStatus::Exhausted { hash_count } => hash_count,
engine_cpu::EngineStatus::Found { hash_count, .. } => hash_count,
other => panic!("unexpected status: {other:?}"),
};
println!(
"{} hashes in {:.3}s = {:.3} MH/s",
hashes,
elapsed,
hashes as f64 / elapsed / 1e6
);
}

View File

@@ -0,0 +1,229 @@
use pow_core::{hash_from_nonce, mining_midstate, JobContext};
use primitive_types::U512;
use std::time::Instant;
fn u512_to_u32s_le(v: U512) -> [u32; 16] {
let bytes = v.to_little_endian();
let mut out = [0u32; 16];
for i in 0..16 {
out[i] = u32::from_le_bytes(bytes[i * 4..(i + 1) * 4].try_into().unwrap());
}
out
}
struct Runner {
device: wgpu::Device,
queue: wgpu::Queue,
pipeline: wgpu::ComputePipeline,
results: wgpu::Buffer,
midstate: wgpu::Buffer,
start_nonce: wgpu::Buffer,
target: wgpu::Buffer,
cfg: wgpu::Buffer,
staging: wgpu::Buffer,
}
impl Runner {
async fn new(trusted: bool) -> Self {
let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor {
backends: wgpu::Backends::PRIMARY,
..Default::default()
});
let adapter = instance
.request_adapter(&wgpu::RequestAdapterOptions::default())
.await
.expect("no adapter");
assert!(
adapter.features().contains(wgpu::Features::SHADER_INT64),
"SHADER_INT64 required"
);
let (device, queue) = adapter
.request_device(&wgpu::DeviceDescriptor {
label: None,
required_features: wgpu::Features::SHADER_INT64,
..Default::default()
})
.await
.unwrap();
let kernel = engine_gpu::Kernel::for_adapter(&adapter);
let desc = wgpu::ShaderModuleDescriptor {
label: Some(kernel.label()),
source: wgpu::ShaderSource::Wgsl(kernel.source().into()),
};
let shader = if trusted {
unsafe {
device.create_shader_module_trusted(desc, wgpu::ShaderRuntimeChecks::unchecked())
}
} else {
device.create_shader_module(desc)
};
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: None,
layout: None,
module: &shader,
entry_point: Some("mining_main"),
compilation_options: Default::default(),
cache: None,
});
let mk = |size: u64, usage| {
device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size,
usage,
mapped_at_creation: false,
})
};
use wgpu::BufferUsages as U;
Runner {
pipeline,
results: mk(132, U::STORAGE | U::COPY_SRC | U::COPY_DST),
midstate: mk(96, U::STORAGE | U::COPY_DST),
start_nonce: mk(64, U::STORAGE | U::COPY_DST),
target: mk(64, U::STORAGE | U::COPY_DST),
cfg: mk(12, U::STORAGE | U::COPY_DST),
staging: mk(132, U::MAP_READ | U::COPY_DST),
device,
queue,
}
}
fn run_batch(&self, header: [u8; 32], start: U512, batch: u32, target: U512) -> f64 {
let nonce_be = start.to_big_endian();
let mid = mining_midstate(header, nonce_be[..32].try_into().unwrap());
let mut mid_u32 = [0u32; 24];
for (i, f) in mid.iter().enumerate() {
mid_u32[2 * i] = *f as u32;
mid_u32[2 * i + 1] = (*f >> 32) as u32;
}
self.queue
.write_buffer(&self.midstate, 0, bytemuck::cast_slice(&mid_u32));
self.queue.write_buffer(
&self.start_nonce,
0,
bytemuck::cast_slice(&u512_to_u32s_le(start)),
);
self.queue.write_buffer(
&self.target,
0,
bytemuck::cast_slice(&u512_to_u32s_le(target)),
);
self.queue
.write_buffer(&self.cfg, 0, bytemuck::cast_slice(&[batch, 1u32, batch]));
self.queue.write_buffer(&self.results, 0, &[0u8; 132]);
let layout = self.pipeline.get_bind_group_layout(0);
let entries: Vec<wgpu::BindGroupEntry<'_>> = [
(0, &self.results),
(1, &self.midstate),
(2, &self.start_nonce),
(3, &self.target),
(4, &self.cfg),
]
.iter()
.map(|(i, b)| wgpu::BindGroupEntry {
binding: *i,
resource: b.as_entire_binding(),
})
.collect();
let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &layout,
entries: &entries,
});
let t0 = Instant::now();
let mut encoder = self
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
{
let mut cpass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: None,
timestamp_writes: None,
});
cpass.set_pipeline(&self.pipeline);
cpass.set_bind_group(0, &bind_group, &[]);
cpass.dispatch_workgroups(batch.div_ceil(256), 1, 1);
}
encoder.copy_buffer_to_buffer(&self.results, 0, &self.staging, 0, 132);
self.queue.submit(Some(encoder.finish()));
let slice = self.staging.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| tx.send(r).unwrap());
loop {
let _ = self.device.poll(wgpu::PollType::Wait {
submission_index: None,
timeout: None,
});
if rx.try_recv().is_ok() {
break;
}
}
let elapsed = t0.elapsed().as_secs_f64();
self.staging.unmap();
elapsed
}
fn read_results(&self) -> [u32; 33] {
let slice = self.staging.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| tx.send(r).unwrap());
let _ = self.device.poll(wgpu::PollType::Wait {
submission_index: None,
timeout: None,
});
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut out = [0u32; 33];
out.copy_from_slice(bytemuck::cast_slice(&data));
drop(data);
self.staging.unmap();
out
}
}
fn main() {
let batch: u32 = std::env::args()
.nth(1)
.map(|s| s.parse().unwrap())
.unwrap_or(8_000_000);
let iters = 4u32;
let rt = tokio::runtime::Runtime::new().unwrap();
for trusted in [false, true] {
let label = if trusted {
"trusted (no checks)"
} else {
"checked (default)"
};
let runner = rt.block_on(Runner::new(trusted));
let header = [9u8; 32];
let ctx = JobContext::new(header, U512::one());
let start = (U512::from(7u64) << 300) | U512::from(123456789u64);
runner.run_batch(header, start, 256, ctx.target);
let r = runner.read_results();
assert_eq!(r[0], 1, "{label}: no solution with target=MAX");
let nonce = U512::from_little_endian(bytemuck::cast_slice(&r[1..17]));
let hash = U512::from_little_endian(bytemuck::cast_slice(&r[17..33]));
assert_eq!(hash, hash_from_nonce(&ctx, nonce), "{label}: hash mismatch");
println!("{label}: correctness OK");
let header = [42u8; 32];
let start = U512::from(1u64) << 200;
let target = U512::one();
runner.run_batch(header, start, batch, target);
let mut total = 0.0;
for i in 0..iters {
total += runner.run_batch(
header,
start + U512::from((i as u64 + 1) * batch as u64),
batch,
target,
);
}
let mhs = (batch as f64 * iters as f64) / total / 1e6;
println!("{label}: {batch} nonces x{iters} in {total:.3}s = {mhs:.3} MH/s");
}
}

View File

@@ -7,6 +7,7 @@ use wgpu::util::DeviceExt;
pub async fn test_end_to_end_mining(
device: &wgpu::Device,
queue: &wgpu::Queue,
shader_src: &str,
) -> Result<(), Box<dyn std::error::Error>> {
println!("Running End-to-End Mining Test...");
@@ -28,15 +29,17 @@ pub async fn test_end_to_end_mining(
// 4. Run GPU Mining for this specific nonce
// Header Buffer
let mut header_u32s = [0u32; 8];
for (i, item) in header_u32s.iter_mut().enumerate() {
let chunk = &ctx.header[i * 4..(i + 1) * 4];
*item = u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
// Midstate Buffer (header + high nonce half absorbed on CPU)
let nonce_be = nonce_val.to_big_endian();
let midstate = pow_core::mining_midstate(ctx.header, nonce_be[..32].try_into().unwrap());
let mut midstate_u32s = [0u32; 24];
for (i, felt) in midstate.iter().enumerate() {
midstate_u32s[2 * i] = *felt as u32;
midstate_u32s[2 * i + 1] = (*felt >> 32) as u32;
}
let header_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Header Buffer"),
contents: bytemuck::cast_slice(&header_u32s),
let midstate_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Midstate Buffer"),
contents: bytemuck::cast_slice(&midstate_u32s),
usage: wgpu::BufferUsages::STORAGE,
});
@@ -82,11 +85,11 @@ pub async fn test_end_to_end_mining(
let zeros = vec![0u8; results_size];
queue.write_buffer(&results_buffer, 0, &zeros);
// Dispatch config buffer: [total_threads, nonces_per_thread, total_nonces, cancel_check_interval]
let dispatch_config_data: [u32; 4] = [256, 1, 256, 10000];
// Dispatch config buffer: [total_threads, nonces_per_thread, total_nonces]
let dispatch_config_data: [u32; 3] = [256, 1, 256];
let dispatch_config_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Dispatch Config Buffer"),
size: 16,
size: 12,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
@@ -96,25 +99,9 @@ pub async fn test_end_to_end_mining(
bytemuck::cast_slice(&dispatch_config_data),
);
// Cancel flag buffer: 0 = running, 1 = cancel requested
let cancel_flag_data: [u32; 1] = [0];
let cancel_flag_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Cancel Flag Buffer"),
size: 4,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
queue.write_buffer(
&cancel_flag_buffer,
0,
bytemuck::cast_slice(&cancel_flag_data),
);
// Load Shader
let shader_source = include_str!("mining.wgsl");
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Mining Shader"),
source: wgpu::ShaderSource::Wgsl(shader_source.into()),
source: wgpu::ShaderSource::Wgsl(shader_src.into()),
});
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
@@ -137,7 +124,7 @@ pub async fn test_end_to_end_mining(
},
wgpu::BindGroupEntry {
binding: 1,
resource: header_buffer.as_entire_binding(),
resource: midstate_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
@@ -151,10 +138,6 @@ pub async fn test_end_to_end_mining(
binding: 4,
resource: dispatch_config_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 5,
resource: cancel_flag_buffer.as_entire_binding(),
},
],
});

View File

@@ -423,6 +423,31 @@ const QUALCOMM_TIERS: &[GpuTier] = &[
];
const APPLE_TIERS: &[GpuTier] = &[
// M5 series
GpuTier {
pattern: r"m5 ultra",
name: "Apple M5 Ultra",
workgroup_divisor: 4,
min_workgroups: 1600,
},
GpuTier {
pattern: r"m5 max",
name: "Apple M5 Max",
workgroup_divisor: 4,
min_workgroups: 800,
},
GpuTier {
pattern: r"m5 pro",
name: "Apple M5 Pro",
workgroup_divisor: 4,
min_workgroups: 400,
},
GpuTier {
pattern: r"\bm5\b",
name: "Apple M5",
workgroup_divisor: 4,
min_workgroups: 200,
},
// M4 series
GpuTier {
pattern: r"m4 ultra",

View File

@@ -191,7 +191,9 @@ const MDS_MATRIX_DIAG_12: array<array<u32, 2>, 12> = array<array<u32, 2>, 12>(
// Storage buffers
@group(0) @binding(0) var<storage, read_write> results: array<atomic<u32>>;
@group(0) @binding(1) var<storage, read> header: array<u32, 8>; // 32 bytes
// Sponge state after absorbing header + high nonce half (12 felts as LE u32 pairs),
// precomputed on the host per batch. See pow_core::mining_midstate.
@group(0) @binding(1) var<storage, read> midstate: array<u32, 24>;
@group(0) @binding(2) var<storage, read> start_nonce: array<u32, 16>; // 64 bytes
@group(0) @binding(3) var<storage, read> difficulty_target: array<u32, 16>; // 64 bytes (U512 target)
@group(0) @binding(4) var<storage, read> dispatch_config: array<u32, 3>; // [total_threads, nonces_per_thread, total_nonces]
@@ -787,17 +789,8 @@ fn hash_squeeze_twice(input: array<u32, 24>) -> array<u32, 16> {
return poseidon2_hash_squeeze_twice(input);
}
// Check if hash < target (U512 comparison)
fn is_below_target(hash: array<u32, 16>, difficulty_tgt: array<u32, 16>) -> bool {
// Compare from most significant to least significant
for (var i = 0u; i < 16u; i++) {
if (hash[15u - i] < difficulty_tgt[15u - i]) {
return true;
} else if (hash[15u - i] > difficulty_tgt[15u - i]) {
return false;
}
}
return false; // Equal, not below
fn bswap32(v: u32) -> u32 {
return ((v & 0xFFu) << 24u) | ((v & 0xFF00u) << 8u) | ((v >> 8u) & 0xFF00u) | (v >> 24u);
}
// Main mining kernel
@@ -834,61 +827,90 @@ fn mining_main(@builtin(global_invocation_id) global_id: vec3<u32>) {
return;
}
// current_nonce = start_nonce + logical_index
// current_nonce = start_nonce + logical_index. The host guarantees a batch
// never carries into the high nonce half (limbs 8..15).
var current_nonce: array<u32, 16>;
var carry: u32 = 0u;
// Add logical_index into the low limb and propagate carry through the U512 nonce
let val0 = start_nonce[0];
let sum0 = val0 + logical_index;
current_nonce[0] = sum0;
carry = select(0u, 1u, sum0 < val0);
// Propagate carry through remaining limbs
for (var i = 1u; i < 16u; i++) {
for (var i = 1u; i < 8u; i++) {
let val = start_nonce[i];
let sum = val + carry;
current_nonce[i] = sum;
carry = select(0u, 1u, sum < val);
}
// Construct input (96 bytes = 24 u32s)
// Header (32 bytes = 8 u32s) followed by Nonce (64 bytes = 16 u32s)
var input: array<u32, 24>;
for (var i = 0u; i < 8u; i++) {
input[i] = header[i];
for (var i = 8u; i < 16u; i++) {
current_nonce[i] = start_nonce[i];
}
// Nonce needs to be Big Endian in the byte stream for hashing.
// current_nonce is Little Endian words.
for (var i = 0u; i < 16u; i++) {
let val = current_nonce[15u - i];
// Reverse bytes
// Resume the sponge from the precomputed midstate: absorb the low nonce
// half (as Big Endian words), pad, squeeze twice (3 permutations instead of 5).
var state: array<GoldilocksField, 12>;
for (var i = 0u; i < 12u; i++) {
state[i] = gf_from_limbs(midstate[2u * i], midstate[2u * i + 1u]);
}
for (var i = 0u; i < 8u; i++) {
let val = current_nonce[7u - i];
let rev = ((val & 0xFFu) << 24u) |
((val & 0xFF00u) << 8u) |
((val & 0xFF0000u) >> 8u) |
((val & 0xFF000000u) >> 24u);
input[8u + i] = rev;
state[i] = gf_add(state[i], gf_from_u32(rev));
}
poseidon2_permute(&state);
state[0] = gf_add(state[0], gf_one());
state[1] = gf_add(state[1], gf_one());
poseidon2_permute(&state);
// Hash (Big Endian)
let hash_be = hash_squeeze_twice(input);
// Convert to Little Endian for difficulty check and storage
// First squeeze yields the most significant 256 bits of the hash, which
// decide hash-vs-target on their own unless they exactly equal the
// target's high half. Only candidates pay for the second squeeze.
let first_output = field_elements_to_bytes(array<GoldilocksField, 4>(
state[0], state[1], state[2], state[3]
));
var hash_le: array<u32, 16>;
for (var i = 0u; i < 16u; i++) {
let val = hash_be[15u - i];
// Reverse bytes in u32
hash_le[i] = ((val & 0xFFu) << 24u) |
((val & 0xFF00u) << 8u) |
((val & 0xFF0000u) >> 8u) |
((val & 0xFF000000u) >> 24u);
for (var i = 0u; i < 8u; i++) {
hash_le[15u - i] = bswap32(first_output[i]);
}
var cmp = 0u;
for (var i = 0u; i < 8u; i++) {
let h = hash_le[15u - i];
let t = difficulty_target[15u - i];
if (h != t) {
cmp = select(2u, 1u, h > t);
break;
}
}
if (cmp == 1u) {
continue;
}
// Check target
if (is_below_target(hash_le, difficulty_target)) {
poseidon2_permute(&state);
let second_output = field_elements_to_bytes(array<GoldilocksField, 4>(
state[0], state[1], state[2], state[3]
));
for (var i = 0u; i < 8u; i++) {
hash_le[i] = bswap32(second_output[7u - i]);
}
var below = cmp == 2u;
if (!below) {
for (var i = 0u; i < 8u; i++) {
let h = hash_le[7u - i];
let t = difficulty_target[7u - i];
if (h != t) {
below = h < t;
break;
}
}
}
if (below) {
// Try to claim the solution
if (atomicExchange(&results[0], 1u) == 0u) {
// We won! Write nonce and hash
// results layout: [0]=found, [1..16]=nonce, [17..32]=hash
for (var i = 0u; i < 16u; i++) {
atomicStore(&results[1u + i], current_nonce[i]);

View File

@@ -0,0 +1,573 @@
// Native-u64 Poseidon2 mining kernel. Default path for non-Apple GPUs.
// Requires wgpu Features::SHADER_INT64. Bit-exact with mining.wgsl / pow_core.
@group(0) @binding(0) var<storage, read_write> results: array<atomic<u32>>;
// Sponge state after absorbing header + high nonce half (12 felts as LE u32 pairs),
// precomputed on the host per batch. See pow_core::mining_midstate.
@group(0) @binding(1) var<storage, read> midstate: array<u32, 24>;
@group(0) @binding(2) var<storage, read> start_nonce: array<u32, 16>;
@group(0) @binding(3) var<storage, read> difficulty_target: array<u32, 16>;
@group(0) @binding(4) var<storage, read> dispatch_config: array<u32, 3>;
const P64: u64 = 0xFFFFFFFF00000001lu;
// EPS64 = 2^32 - 1 = 2^64 mod P
const EPS64: u64 = 0xFFFFFFFFlu;
const RC_INTERNAL: array<u64, 22> = array<u64, 22>(
0x97f7798a784ad863lu, 0xd1d2bf082f60d4f0lu, 0x69a377a79f9ad206lu, 0xa9d06906a3858e24lu, 0x295275001eede5b5lu, 0x5874e441117bd746lu, 0x8a084bbba8ed86cclu, 0x3defd7645cde6425lu, 0x3998cfe6871cc137lu, 0x3e52ef8bca48314alu, 0x964a209f85dc9ecclu, 0x3fcc9ee82cc4577elu, 0x8e79b4a5d0096d6dlu, 0x8492362ad2392556lu, 0xee72f470262574d6lu, 0x1e0e18496da2444alu, 0x0f3a74bf215eaac6lu, 0x1b061b76a1c0ded3lu, 0x192c42d86803d7a6lu, 0xf6d49ff997ae0260lu, 0x3ec372e7a0fa3786lu, 0x5538cdf4f23445d3lu
);
const RC_INITIAL: array<array<u64, 12>, 4> = array<array<u64, 12>, 4>(
array<u64, 12>(0xc002e770975b1607lu, 0xbca51a8dfe14593alu, 0x72938dfbe774f7f9lu, 0xe4f2fe29e03234aclu, 0xd5e0ba2f541b6449lu, 0xec33b868f3cc46c1lu, 0x486dcb55419d475alu, 0x6c1cb2a358cc24f1lu, 0xe3f30d509a1436bblu, 0xd9a64f068dca7c29lu, 0xe59b3f57aabba1aelu, 0x2a3dd4505b478fdclu),
array<u64, 12>(0xada1f8dc7676ed25lu, 0x2711aa8b5509d516lu, 0x4ae6acd0c9c92897lu, 0x56eb3d6b5256d67alu, 0x1f7a9d55923bf51elu, 0x3600427d397a7f68lu, 0xe5076df75b72c3d0lu, 0xfcd59aa12c6090adlu, 0xcd895e8c68b57a9elu, 0x41df7ef9d730ae3elu, 0xee3e2b889abe977dlu, 0xd29bb7edbeb9c405lu),
array<u64, 12>(0x7d5c08eef608e382lu, 0x89ae889caaf0802clu, 0xb35a8e976d2af617lu, 0xdb14234eafaf5173lu, 0x78f04462d48b1c98lu, 0x265293b0e47ce88alu, 0x999a649b69b9d32flu, 0x64b0a186698e01d3lu, 0xee0b22d0dfae8bb8lu, 0x4fd53e50ca04a7eelu, 0x5762bfe181f25047lu, 0xf51593e2beb5e3bdlu),
array<u64, 12>(0x1e5e2b5760e32477lu, 0x622462a1f9aaaeedlu, 0xaa284b3ecdb222aelu, 0x63c8e72f542bf3fclu, 0x3ba588cacb43b5e0lu, 0x23eda6f3c99150ddlu, 0xaad3bea4baac9a5alu, 0xe9da8d699b94184alu, 0xcdb13f4cd93e024clu, 0x902cbd0956f655e3lu, 0x5b4e40ffc759532flu, 0xde795c20a2357af7lu)
);
const RC_TERMINAL: array<array<u64, 12>, 4> = array<array<u64, 12>, 4>(
array<u64, 12>(0x7b72c539e0ea4c6elu, 0x144573dae2ce9976lu, 0x802028b68f35fc88lu, 0x6d36c5022c4fe7c2lu, 0xa205d0ffa9b9def3lu, 0xf6e7e38b1ea6ba2flu, 0x34f7909ae5258d64lu, 0xb0464d9d77b97fcalu, 0x64ddb9d5de7e00a6lu, 0x0ed0d75c27975d97lu, 0x1cbb36f11127338blu, 0x6673e505cfd0b6balu),
array<u64, 12>(0x605f902830872e01lu, 0x3fd5eb927e95fe4flu, 0xe81025b5a24c69cdlu, 0xf7d0ce75de23f74elu, 0xf39942b6a8585089lu, 0x6d808a08f7b71df6lu, 0xf8806b6588f49a8blu, 0x57df2d8c2a32107alu, 0x16e7c2074d654a2dlu, 0x213de241fcf33835lu, 0xb0f2b8905a0976f6lu, 0xd8e3cf2bbd355417lu),
array<u64, 12>(0xe498691679d9330flu, 0x763b45d2a3821b28lu, 0x0908bf65eb0a1f0dlu, 0x7691eb2d194b24f4lu, 0x0e43551233ae13b2lu, 0x93c393dbfc2fe76flu, 0x98f607485d48cdealu, 0xe3d95f30309819c0lu, 0x1ef581a93eaf6acflu, 0x0b24c1b7a030fca4lu, 0x624370be5670b327lu, 0x5f1e28615a11e486lu),
array<u64, 12>(0xfe04051f909e042blu, 0x7257e5b147fd3803lu, 0xe6ae134bb82f2e78lu, 0x5711fd5cf4784511lu, 0xf83a42660c08c0bclu, 0x2cd8c96d9a3ce855lu, 0x7d2ffb1bb0e17271lu, 0x85ae1528caea3811lu, 0x52a345d5c7adb0b8lu, 0x504c4c51f3faee94lu, 0xbce34a649cfccaf9lu, 0xe0a3389266fb6dc9lu)
);
const MDS_DIAG: array<u64, 12> = array<u64, 12>(
0xc3b6c08e23ba9300lu, 0xd84b5de94a324fb6lu, 0x0d0c371c5b35b84flu, 0x7964f570e7188037lu, 0x5daf18bbd996604blu, 0x6743bc47b9595257lu, 0x5528b9362c59bb70lu, 0xac45e25b7127b68blu, 0xa2077d7dfbb606b5lu, 0xf3faac6faee378aelu, 0x0c6388b51545e883lu, 0xd27dbb6944917b60lu
);
// a + b mod P in lazy form. Wrapping carries fold back via 2^64 ≡ EPS64 (mod P).
fn gf64_add(a: u64, b: u64) -> u64 {
let s0 = a + b;
let c1 = s0 < a;
let s1 = s0 + select(0lu, EPS64, c1);
let c2 = c1 && (s1 < s0);
return s1 + select(0lu, EPS64, c2);
}
// Reduce a 128-bit value (lo + hi*2^64) mod P using
// 2^64 ≡ EPS64 and 2^96 ≡ -1 (mod P).
fn gf64_reduce(lo: u64, hi: u64) -> u64 {
let hi_hi = hi >> 32u;
let hi_lo = hi & EPS64;
var t0 = lo - hi_hi;
t0 = t0 - select(0lu, EPS64, lo < hi_hi);
let t1 = hi_lo * EPS64;
let t2 = t0 + t1;
return t2 + select(0lu, EPS64, t2 < t0);
}
fn gf64_mul(a: u64, b: u64) -> u64 {
let a_lo = a & EPS64;
let a_hi = a >> 32u;
let b_lo = b & EPS64;
let b_hi = b >> 32u;
let ll = a_lo * b_lo;
let lh = a_lo * b_hi;
let hl = a_hi * b_lo;
let hh = a_hi * b_hi;
let mid = lh + hl;
let mid_c = select(0lu, 1lu, mid < lh);
let lo = ll + (mid << 32u);
let lo_c = select(0lu, 1lu, lo < ll);
let hi = hh + (mid >> 32u) + (mid_c << 32u) + lo_c;
return gf64_reduce(lo, hi);
}
fn gf64_sqr(a: u64) -> u64 {
let a_lo = a & EPS64;
let a_hi = a >> 32u;
let ll = a_lo * a_lo;
let lh = a_lo * a_hi;
let hh = a_hi * a_hi;
let mid = lh << 1u;
let mid_c = lh >> 63u;
let lo = ll + (mid << 32u);
let lo_c = select(0lu, 1lu, lo < ll);
let hi = hh + (mid >> 32u) + (mid_c << 32u) + lo_c;
return gf64_reduce(lo, hi);
}
fn gf64_sbox(x: u64) -> u64 {
let x2 = gf64_sqr(x);
let x4 = gf64_sqr(x2);
let x6 = gf64_mul(x4, x2);
return gf64_mul(x6, x);
}
fn gf64_canon(a: u64) -> u64 {
return a - select(0lu, P64, a >= P64);
}
// External linear layer: 4x4 MDS on each chunk, then circulant sums.
fn ext_layer64(state: ptr<function, array<u64, 12>>) {
for (var chunk = 0u; chunk < 3u; chunk++) {
let o = chunk * 4u;
let x0 = (*state)[o];
let x1 = (*state)[o + 1u];
let x2 = (*state)[o + 2u];
let x3 = (*state)[o + 3u];
let t01 = gf64_add(x0, x1);
let t23 = gf64_add(x2, x3);
let t0123 = gf64_add(t01, t23);
let t01123 = gf64_add(t0123, x1);
let t01233 = gf64_add(t0123, x3);
(*state)[o + 3u] = gf64_add(t01233, gf64_add(x0, x0));
(*state)[o + 1u] = gf64_add(t01123, gf64_add(x2, x2));
(*state)[o] = gf64_add(t01123, t01);
(*state)[o + 2u] = gf64_add(t01233, t23);
}
var sums: array<u64, 4>;
for (var k = 0u; k < 4u; k++) {
sums[k] = gf64_add(gf64_add((*state)[k], (*state)[k + 4u]), (*state)[k + 8u]);
}
for (var i = 0u; i < 12u; i++) {
(*state)[i] = gf64_add((*state)[i], sums[i % 4u]);
}
}
// Internal linear layer: diagonal matrix plus full sum.
fn int_layer64(state: ptr<function, array<u64, 12>>) {
var sum = (*state)[0];
for (var i = 1u; i < 12u; i++) {
sum = gf64_add(sum, (*state)[i]);
}
for (var i = 0u; i < 12u; i++) {
(*state)[i] = gf64_add(gf64_mul((*state)[i], MDS_DIAG[i]), sum);
}
}
fn permute64(state: ptr<function, array<u64, 12>>) {
ext_layer64(state);
for (var r = 0u; r < 4u; r++) {
for (var i = 0u; i < 12u; i++) {
(*state)[i] = gf64_add((*state)[i], RC_INITIAL[r][i]);
}
for (var i = 0u; i < 12u; i++) {
(*state)[i] = gf64_sbox((*state)[i]);
}
ext_layer64(state);
}
for (var r = 0u; r < 22u; r++) {
(*state)[0] = gf64_sbox(gf64_add((*state)[0], RC_INTERNAL[r]));
int_layer64(state);
}
for (var r = 0u; r < 4u; r++) {
for (var i = 0u; i < 12u; i++) {
(*state)[i] = gf64_add((*state)[i], RC_TERMINAL[r][i]);
}
for (var i = 0u; i < 12u; i++) {
(*state)[i] = gf64_sbox((*state)[i]);
}
ext_layer64(state);
}
}
fn bswap32(v: u32) -> u32 {
return ((v & 0xFFu) << 24u) | ((v & 0xFF00u) << 8u) | ((v >> 8u) & 0xFF00u) | (v >> 24u);
}
@compute @workgroup_size(256)
fn mining_main(@builtin(global_invocation_id) global_id: vec3<u32>) {
if (atomicLoad(&results[0]) != 0u) {
return;
}
let thread_id = global_id.x;
let total_threads = dispatch_config[0];
let nonces_per_thread = dispatch_config[1];
let total_nonces = dispatch_config[2];
if (thread_id >= total_threads) {
return;
}
let base_index = thread_id * nonces_per_thread;
// Hoist uniform storage reads out of the nonce loop
var mid: array<u64, 12>;
for (var i = 0u; i < 12u; i++) {
mid[i] = (u64(midstate[2u * i + 1u]) << 32u) | u64(midstate[2u * i]);
}
var tgt: array<u32, 16>;
for (var i = 0u; i < 16u; i++) {
tgt[i] = difficulty_target[i];
}
var nonce_base: array<u32, 16>;
for (var i = 0u; i < 16u; i++) {
nonce_base[i] = start_nonce[i];
}
for (var j = 0u; j < nonces_per_thread; j = j + 1u) {
let logical_index = base_index + j;
if (logical_index >= total_nonces) {
break;
}
if (j > 0u && atomicLoad(&results[0]) != 0u) {
return;
}
// The host guarantees a batch never carries into the high nonce half
// (limbs 8..15), so only the low 256 bits are incremented here.
var current_nonce: array<u32, 16>;
let val0 = nonce_base[0];
let sum0 = val0 + logical_index;
current_nonce[0] = sum0;
var carry = select(0u, 1u, sum0 < val0);
for (var i = 1u; i < 8u; i++) {
let val = nonce_base[i];
let sum = val + carry;
current_nonce[i] = sum;
carry = select(0u, 1u, sum < val);
}
for (var i = 8u; i < 16u; i++) {
current_nonce[i] = nonce_base[i];
}
// Resume the sponge from the precomputed midstate: absorb the low
// nonce half, pad, squeeze twice (3 permutations instead of 5).
var st: array<u64, 12>;
for (var i = 0u; i < 12u; i++) {
st[i] = mid[i];
}
for (var i = 0u; i < 8u; i++) {
st[i] = gf64_add(st[i], u64(bswap32(current_nonce[7u - i])));
}
permute64(&st);
st[0] = gf64_add(st[0], 1lu);
st[1] = gf64_add(st[1], 1lu);
permute64(&st);
// First squeeze yields the most significant 256 bits of the hash, which
// decide hash-vs-target on their own unless they exactly equal the
// target's high half. Only candidates pay for the second squeeze, and
// byte-swapped hash words are produced on demand during the compare.
var first: array<u32, 8>;
for (var i = 0u; i < 4u; i++) {
let c = gf64_canon(st[i]);
first[2u * i] = u32(c & EPS64);
first[2u * i + 1u] = u32(c >> 32u);
}
var cmp = 0u;
for (var i = 0u; i < 8u; i++) {
let h = bswap32(first[i]);
let t = tgt[15u - i];
if (h != t) {
cmp = select(2u, 1u, h > t);
break;
}
}
if (cmp == 1u) {
continue;
}
var hash_le: array<u32, 16>;
for (var i = 0u; i < 8u; i++) {
hash_le[15u - i] = bswap32(first[i]);
}
permute64(&st);
for (var i = 0u; i < 4u; i++) {
let c = gf64_canon(st[i]);
hash_le[7u - 2u * i] = bswap32(u32(c & EPS64));
hash_le[6u - 2u * i] = bswap32(u32(c >> 32u));
}
var below = cmp == 2u;
if (!below) {
for (var i = 0u; i < 8u; i++) {
let h = hash_le[7u - i];
let t = tgt[7u - i];
if (h != t) {
below = h < t;
break;
}
}
}
if (below) {
if (atomicExchange(&results[0], 1u) == 0u) {
for (var i = 0u; i < 16u; i++) {
atomicStore(&results[1u + i], current_nonce[i]);
atomicStore(&results[17u + i], hash_le[i]);
}
}
return;
}
}
}
// ---------------------------------------------------------------------------
// Compatibility layer: same API as mining.wgsl, backed by the u64 core above.
// Only used by the component test harness; the mining kernel never calls it.
// All outputs are canonical, matching the reference implementation.
// ---------------------------------------------------------------------------
struct GoldilocksField {
limb0: u32,
limb1: u32,
}
const INTERNAL_CONSTANTS: array<array<u32, 2>, 22> = array<array<u32, 2>, 22>(
array<u32, 2>(2018170979u, 2549578122u),
array<u32, 2>(794875120u, 3520249608u),
array<u32, 2>(2677723654u, 1772320679u),
array<u32, 2>(2743438884u, 2849007878u),
array<u32, 2>(518907317u, 693269760u),
array<u32, 2>(293328710u, 1484055617u),
array<u32, 2>(2834138828u, 2315799483u),
array<u32, 2>(1558078501u, 1039128420u),
array<u32, 2>(2266808631u, 966316006u),
array<u32, 2>(3393728842u, 1045622667u),
array<u32, 2>(2245828300u, 2521440415u),
array<u32, 2>(751064958u, 1070374632u),
array<u32, 2>(3490278765u, 2390340773u),
array<u32, 2>(3526960470u, 2224174634u),
array<u32, 2>(639988950u, 4000511088u),
array<u32, 2>(1839350858u, 504240201u),
array<u32, 2>(559852230u, 255489215u),
array<u32, 2>(2713771731u, 453385078u),
array<u32, 2>(1745082278u, 422331096u),
array<u32, 2>(2544763488u, 4141129721u),
array<u32, 2>(2700752774u, 1052996327u),
array<u32, 2>(4063512019u, 1429786100u)
);
const INITIAL_EXTERNAL_CONSTANTS: array<array<array<u32, 2>, 12>, 4> = array<array<array<u32, 2>, 12>, 4>(
array<array<u32, 2>, 12>(
array<u32, 2>(2539329031u, 3221415792u),
array<u32, 2>(4262746426u, 3164936845u),
array<u32, 2>(3883202553u, 1922272763u),
array<u32, 2>(3761386668u, 3841130025u),
array<u32, 2>(1411081289u, 3588274735u),
array<u32, 2>(4090250945u, 3962812520u),
array<u32, 2>(1100826458u, 1215155029u),
array<u32, 2>(1489773809u, 1813820067u),
array<u32, 2>(2585015995u, 3824356688u),
array<u32, 2>(2378857513u, 3651555078u),
array<u32, 2>(2864423342u, 3852156759u),
array<u32, 2>(1531416540u, 708695120u)
),
array<array<u32, 2>, 12>(
array<u32, 2>(1987505445u, 2913073372u),
array<u32, 2>(1426707734u, 655469195u),
array<u32, 2>(3385403543u, 1256631504u),
array<u32, 2>(1381422714u, 1458257259u),
array<u32, 2>(2453402910u, 528129365u),
array<u32, 2>(964329320u, 905986685u),
array<u32, 2>(1534247888u, 3842469367u),
array<u32, 2>(744525997u, 4241857185u),
array<u32, 2>(1756723870u, 3448331916u),
array<u32, 2>(3610291774u, 1105166073u),
array<u32, 2>(2596181885u, 3997051784u),
array<u32, 2>(3199845381u, 3533420525u)
),
array<array<u32, 2>, 12>(
array<u32, 2>(4127777666u, 2103183598u),
array<u32, 2>(2867888172u, 2309916828u),
array<u32, 2>(1831532055u, 3009056407u),
array<u32, 2>(2947502451u, 3675530062u),
array<u32, 2>(3565886616u, 2029012066u),
array<u32, 2>(3833391242u, 642945968u),
array<u32, 2>(1773785903u, 2577032347u),
array<u32, 2>(1770914259u, 1689297286u),
array<u32, 2>(3752758200u, 3993707216u),
array<u32, 2>(3389302766u, 1339375184u),
array<u32, 2>(2180141127u, 1466089441u),
array<u32, 2>(3199591357u, 4111832034u)
),
array<array<u32, 2>, 12>(
array<u32, 2>(1625498743u, 509487959u),
array<u32, 2>(4188712685u, 1646551713u),
array<u32, 2>(3451003566u, 2854767422u),
array<u32, 2>(1412166652u, 1674110767u),
array<u32, 2>(3410212320u, 1000704202u),
array<u32, 2>(3381743837u, 602777331u),
array<u32, 2>(3131873882u, 2866003620u),
array<u32, 2>(2610174026u, 3923414377u),
array<u32, 2>(3644719692u, 3450945356u),
array<u32, 2>(1458984419u, 2418851081u),
array<u32, 2>(3344519983u, 1531855103u),
array<u32, 2>(2721413879u, 3732495392u)
)
);
const TERMINAL_EXTERNAL_CONSTANTS: array<array<array<u32, 2>, 12>, 4> = array<array<array<u32, 2>, 12>, 4>(
array<array<u32, 2>, 12>(
array<u32, 2>(3773451374u, 2071119161u),
array<u32, 2>(3805190518u, 340095962u),
array<u32, 2>(2402679944u, 2149591222u),
array<u32, 2>(743434178u, 1832305922u),
array<u32, 2>(2847530739u, 2718290175u),
array<u32, 2>(514243119u, 4142392203u),
array<u32, 2>(3844443492u, 888639642u),
array<u32, 2>(2008645578u, 2957397405u),
array<u32, 2>(3732799654u, 1692252629u),
array<u32, 2>(664231319u, 248567644u),
array<u32, 2>(287781771u, 482031345u),
array<u32, 2>(3486561978u, 1718871301u)
),
array<array<u32, 2>, 12>(
array<u32, 2>(814165505u, 1616875560u),
array<u32, 2>(2123759183u, 1070984082u),
array<u32, 2>(2722916813u, 3893372341u),
array<u32, 2>(3726899022u, 4157656693u),
array<u32, 2>(2824360073u, 4086907574u),
array<u32, 2>(4155973110u, 1837140488u),
array<u32, 2>(2297731723u, 4169165669u),
array<u32, 2>(707924090u, 1474243980u),
array<u32, 2>(1298483757u, 384287239u),
array<u32, 2>(4243798069u, 557703745u),
array<u32, 2>(1510569718u, 2968696976u),
array<u32, 2>(3174388759u, 3638808363u)
),
array<array<u32, 2>, 12>(
array<u32, 2>(2044277519u, 3835193622u),
array<u32, 2>(2743212840u, 1983595986u),
array<u32, 2>(3943309069u, 151568229u),
array<u32, 2>(424355060u, 1989274413u),
array<u32, 2>(867046322u, 239293714u),
array<u32, 2>(4230997871u, 2479068123u),
array<u32, 2>(1565052394u, 2566260552u),
array<u32, 2>(815274432u, 3822673712u),
array<u32, 2>(1051683535u, 519405993u),
array<u32, 2>(2687564964u, 186958263u),
array<u32, 2>(1450226471u, 1648586942u),
array<u32, 2>(1511122054u, 1595811937u)
),
array<array<u32, 2>, 12>(
array<u32, 2>(2426274859u, 4261676319u),
array<u32, 2>(1207777283u, 1918363057u),
array<u32, 2>(3090099832u, 3870167883u),
array<u32, 2>(4101522705u, 1460796764u),
array<u32, 2>(201900220u, 4164567654u),
array<u32, 2>(2587682901u, 752404845u),
array<u32, 2>(2967564913u, 2100296475u),
array<u32, 2>(3404347409u, 2242778408u),
array<u32, 2>(3350048952u, 1386431957u),
array<u32, 2>(4093308564u, 1347177553u),
array<u32, 2>(2633812729u, 3169012324u),
array<u32, 2>(1727753673u, 3768793234u)
)
);
fn gf_pack(g: GoldilocksField) -> u64 {
return (u64(g.limb1) << 32u) | u64(g.limb0);
}
fn gf_unpack(v: u64) -> GoldilocksField {
let c = gf64_canon(v);
return GoldilocksField(u32(c & EPS64), u32(c >> 32u));
}
fn gf_from_limbs(l0: u32, l1: u32) -> GoldilocksField {
return GoldilocksField(l0, l1);
}
fn gf_zero() -> GoldilocksField {
return GoldilocksField(0u, 0u);
}
fn gf_one() -> GoldilocksField {
return GoldilocksField(1u, 0u);
}
fn gf_from_u32(val: u32) -> GoldilocksField {
return GoldilocksField(val, 0u);
}
fn gf_from_u64_parts(low: u32, high: u32) -> GoldilocksField {
return gf_unpack((u64(high) << 32u) | u64(low));
}
fn gf_from_const(val: array<u32, 2>) -> GoldilocksField {
return gf_from_u64_parts(val[0], val[1]);
}
fn gf_add(a: GoldilocksField, b: GoldilocksField) -> GoldilocksField {
return gf_unpack(gf64_add(gf_pack(a), gf_pack(b)));
}
fn gf_mul(a: GoldilocksField, b: GoldilocksField) -> GoldilocksField {
return gf_unpack(gf64_mul(gf_pack(a), gf_pack(b)));
}
fn sbox(x: GoldilocksField) -> GoldilocksField {
return gf_unpack(gf64_sbox(gf_pack(x)));
}
fn state_pack(state: ptr<function, array<GoldilocksField, 12>>, out: ptr<function, array<u64, 12>>) {
for (var i = 0u; i < 12u; i++) {
(*out)[i] = gf_pack((*state)[i]);
}
}
fn state_unpack(v: ptr<function, array<u64, 12>>, state: ptr<function, array<GoldilocksField, 12>>) {
for (var i = 0u; i < 12u; i++) {
(*state)[i] = gf_unpack((*v)[i]);
}
}
fn external_linear_layer(state: ptr<function, array<GoldilocksField, 12>>) {
var st: array<u64, 12>;
state_pack(state, &st);
ext_layer64(&st);
state_unpack(&st, state);
}
fn internal_linear_layer(state: ptr<function, array<GoldilocksField, 12>>) {
var st: array<u64, 12>;
state_pack(state, &st);
int_layer64(&st);
state_unpack(&st, state);
}
fn poseidon2_permute(state: ptr<function, array<GoldilocksField, 12>>) {
var st: array<u64, 12>;
state_pack(state, &st);
permute64(&st);
state_unpack(&st, state);
}
fn bytes_to_field_elements(input: array<u32, 24>) -> array<GoldilocksField, 25> {
var felts: array<GoldilocksField, 25>;
for (var i = 0u; i < 24u; i++) {
felts[i] = gf_from_u32(input[i]);
}
felts[24] = gf_one();
return felts;
}
fn field_elements_to_bytes(felts: array<GoldilocksField, 4>) -> array<u32, 8> {
var result: array<u32, 8>;
for (var i = 0u; i < 4u; i++) {
result[i * 2u] = felts[i].limb0;
result[i * 2u + 1u] = felts[i].limb1;
}
return result;
}
fn poseidon2_hash_squeeze_twice(input: array<u32, 24>) -> array<u32, 16> {
var st: array<u64, 12>;
for (var i = 0u; i < 12u; i++) {
st[i] = 0lu;
}
for (var chunk = 0u; chunk < 3u; chunk++) {
for (var i = 0u; i < 8u; i++) {
st[i] = gf64_add(st[i], u64(input[chunk * 8u + i]));
}
permute64(&st);
}
st[0] = gf64_add(st[0], 1lu);
st[1] = gf64_add(st[1], 1lu);
permute64(&st);
var result: array<u32, 16>;
for (var i = 0u; i < 4u; i++) {
let c = gf64_canon(st[i]);
result[2u * i] = u32(c & EPS64);
result[2u * i + 1u] = u32(c >> 32u);
}
permute64(&st);
for (var i = 0u; i < 4u; i++) {
let c = gf64_canon(st[i]);
result[8u + 2u * i] = u32(c & EPS64);
result[8u + 2u * i + 1u] = u32(c >> 32u);
}
return result;
}
fn hash_squeeze_twice(input: array<u32, 24>) -> array<u32, 16> {
return poseidon2_hash_squeeze_twice(input);
}

View File

@@ -0,0 +1,650 @@
// Native-u64 Poseidon2 mining kernel. Apple Metal path only.
// Requires wgpu Features::SHADER_INT64. Bit-exact with mining.wgsl / pow_core.
@group(0) @binding(0) var<storage, read_write> results: array<atomic<u32>>;
// Sponge state after absorbing header + high nonce half (12 felts as LE u32 pairs),
// precomputed on the host per batch. See pow_core::mining_midstate.
@group(0) @binding(1) var<storage, read> midstate: array<u32, 24>;
@group(0) @binding(2) var<storage, read> start_nonce: array<u32, 16>;
@group(0) @binding(3) var<storage, read> difficulty_target: array<u32, 16>;
@group(0) @binding(4) var<storage, read> dispatch_config: array<u32, 3>;
const P64: u64 = 0xFFFFFFFF00000001lu;
// EPS64 = 2^32 - 1 = 2^64 mod P
const EPS64: u64 = 0xFFFFFFFFlu;
// Padded with a trailing zero so the internal layer can add the next round's
// constant unconditionally.
const RC_INTERNAL: array<u64, 23> = array<u64, 23>(
0x97f7798a784ad863lu, 0xd1d2bf082f60d4f0lu, 0x69a377a79f9ad206lu, 0xa9d06906a3858e24lu, 0x295275001eede5b5lu, 0x5874e441117bd746lu, 0x8a084bbba8ed86cclu, 0x3defd7645cde6425lu, 0x3998cfe6871cc137lu, 0x3e52ef8bca48314alu, 0x964a209f85dc9ecclu, 0x3fcc9ee82cc4577elu, 0x8e79b4a5d0096d6dlu, 0x8492362ad2392556lu, 0xee72f470262574d6lu, 0x1e0e18496da2444alu, 0x0f3a74bf215eaac6lu, 0x1b061b76a1c0ded3lu, 0x192c42d86803d7a6lu, 0xf6d49ff997ae0260lu, 0x3ec372e7a0fa3786lu, 0x5538cdf4f23445d3lu, 0lu
);
const RC_INITIAL: array<array<u64, 12>, 4> = array<array<u64, 12>, 4>(
array<u64, 12>(0xc002e770975b1607lu, 0xbca51a8dfe14593alu, 0x72938dfbe774f7f9lu, 0xe4f2fe29e03234aclu, 0xd5e0ba2f541b6449lu, 0xec33b868f3cc46c1lu, 0x486dcb55419d475alu, 0x6c1cb2a358cc24f1lu, 0xe3f30d509a1436bblu, 0xd9a64f068dca7c29lu, 0xe59b3f57aabba1aelu, 0x2a3dd4505b478fdclu),
array<u64, 12>(0xada1f8dc7676ed25lu, 0x2711aa8b5509d516lu, 0x4ae6acd0c9c92897lu, 0x56eb3d6b5256d67alu, 0x1f7a9d55923bf51elu, 0x3600427d397a7f68lu, 0xe5076df75b72c3d0lu, 0xfcd59aa12c6090adlu, 0xcd895e8c68b57a9elu, 0x41df7ef9d730ae3elu, 0xee3e2b889abe977dlu, 0xd29bb7edbeb9c405lu),
array<u64, 12>(0x7d5c08eef608e382lu, 0x89ae889caaf0802clu, 0xb35a8e976d2af617lu, 0xdb14234eafaf5173lu, 0x78f04462d48b1c98lu, 0x265293b0e47ce88alu, 0x999a649b69b9d32flu, 0x64b0a186698e01d3lu, 0xee0b22d0dfae8bb8lu, 0x4fd53e50ca04a7eelu, 0x5762bfe181f25047lu, 0xf51593e2beb5e3bdlu),
array<u64, 12>(0x1e5e2b5760e32477lu, 0x622462a1f9aaaeedlu, 0xaa284b3ecdb222aelu, 0x63c8e72f542bf3fclu, 0x3ba588cacb43b5e0lu, 0x23eda6f3c99150ddlu, 0xaad3bea4baac9a5alu, 0xe9da8d699b94184alu, 0xcdb13f4cd93e024clu, 0x902cbd0956f655e3lu, 0x5b4e40ffc759532flu, 0xde795c20a2357af7lu)
);
const RC_TERMINAL: array<array<u64, 12>, 4> = array<array<u64, 12>, 4>(
array<u64, 12>(0x7b72c539e0ea4c6elu, 0x144573dae2ce9976lu, 0x802028b68f35fc88lu, 0x6d36c5022c4fe7c2lu, 0xa205d0ffa9b9def3lu, 0xf6e7e38b1ea6ba2flu, 0x34f7909ae5258d64lu, 0xb0464d9d77b97fcalu, 0x64ddb9d5de7e00a6lu, 0x0ed0d75c27975d97lu, 0x1cbb36f11127338blu, 0x6673e505cfd0b6balu),
array<u64, 12>(0x605f902830872e01lu, 0x3fd5eb927e95fe4flu, 0xe81025b5a24c69cdlu, 0xf7d0ce75de23f74elu, 0xf39942b6a8585089lu, 0x6d808a08f7b71df6lu, 0xf8806b6588f49a8blu, 0x57df2d8c2a32107alu, 0x16e7c2074d654a2dlu, 0x213de241fcf33835lu, 0xb0f2b8905a0976f6lu, 0xd8e3cf2bbd355417lu),
array<u64, 12>(0xe498691679d9330flu, 0x763b45d2a3821b28lu, 0x0908bf65eb0a1f0dlu, 0x7691eb2d194b24f4lu, 0x0e43551233ae13b2lu, 0x93c393dbfc2fe76flu, 0x98f607485d48cdealu, 0xe3d95f30309819c0lu, 0x1ef581a93eaf6acflu, 0x0b24c1b7a030fca4lu, 0x624370be5670b327lu, 0x5f1e28615a11e486lu),
array<u64, 12>(0xfe04051f909e042blu, 0x7257e5b147fd3803lu, 0xe6ae134bb82f2e78lu, 0x5711fd5cf4784511lu, 0xf83a42660c08c0bclu, 0x2cd8c96d9a3ce855lu, 0x7d2ffb1bb0e17271lu, 0x85ae1528caea3811lu, 0x52a345d5c7adb0b8lu, 0x504c4c51f3faee94lu, 0xbce34a649cfccaf9lu, 0xe0a3389266fb6dc9lu)
);
const MDS_DIAG: array<u64, 12> = array<u64, 12>(
0xc3b6c08e23ba9300lu, 0xd84b5de94a324fb6lu, 0x0d0c371c5b35b84flu, 0x7964f570e7188037lu, 0x5daf18bbd996604blu, 0x6743bc47b9595257lu, 0x5528b9362c59bb70lu, 0xac45e25b7127b68blu, 0xa2077d7dfbb606b5lu, 0xf3faac6faee378aelu, 0x0c6388b51545e883lu, 0xd27dbb6944917b60lu
);
// a + b mod P in lazy form. Wrapping carries fold back via 2^64 ≡ EPS64 (mod P).
fn gf64_add(a: u64, b: u64) -> u64 {
let s0 = a + b;
let c1 = s0 < a;
let s1 = s0 + select(0lu, EPS64, c1);
let c2 = c1 && (s1 < s0);
return s1 + select(0lu, EPS64, c2);
}
// Sum accumulator: value = lo + carries * 2^64, folded once via 2^64 ≡ EPS64 (mod P).
struct Acc {
lo: u64,
carries: u32,
}
fn acc_add(a: Acc, b: u64) -> Acc {
let s = a.lo + b;
return Acc(s, a.carries + select(0u, 1u, s < a.lo));
}
fn acc_add2(a: Acc, b: Acc) -> Acc {
let s = a.lo + b.lo;
return Acc(s, a.carries + b.carries + select(0u, 1u, s < a.lo));
}
fn acc_fold(a: Acc) -> u64 {
let c = u64(a.carries);
let t = a.lo + ((c << 32u) - c);
return t + select(0lu, EPS64, t < a.lo);
}
struct U128 {
lo: u64,
hi: u64,
}
// Reduce a 128-bit value (lo + hi*2^64) mod P using
// 2^64 ≡ EPS64 and 2^96 ≡ -1 (mod P).
fn gf64_reduce(v: U128) -> u64 {
let hi_hi = v.hi >> 32u;
let hi_lo = v.hi & EPS64;
var t0 = v.lo - hi_hi;
t0 = t0 - select(0lu, EPS64, v.lo < hi_hi);
let t1 = hi_lo * EPS64;
let t2 = t0 + t1;
return t2 + select(0lu, EPS64, t2 < t0);
}
fn mul_wide(a: u64, b: u64) -> U128 {
let a_lo = a & EPS64;
let a_hi = a >> 32u;
let b_lo = b & EPS64;
let b_hi = b >> 32u;
let ll = a_lo * b_lo;
let lh = a_lo * b_hi;
let hl = a_hi * b_lo;
let hh = a_hi * b_hi;
let mid = (ll >> 32u) + (lh & EPS64) + (hl & EPS64);
return U128((mid << 32u) | (ll & EPS64), hh + (lh >> 32u) + (hl >> 32u) + (mid >> 32u));
}
// (a*b + addend) mod P for b <= 2^64 - 2^32 (all MDS_DIAG entries): the addend's
// value and carries are folded into the 128-bit product before reduction.
fn gf64_mul_add(a: u64, b: u64, addend: Acc) -> u64 {
let v = mul_wide(a, b);
let lo = v.lo + addend.lo;
return gf64_reduce(U128(lo, v.hi + u64(addend.carries) + select(0lu, 1lu, lo < v.lo)));
}
fn gf64_mul(a: u64, b: u64) -> u64 {
return gf64_reduce(mul_wide(a, b));
}
fn gf64_sqr(a: u64) -> u64 {
let a_lo = a & EPS64;
let a_hi = a >> 32u;
let ll = a_lo * a_lo;
let lh = a_lo * a_hi;
let hh = a_hi * a_hi;
let mid = (ll >> 32u) + ((lh & EPS64) << 1u);
return gf64_reduce(U128((mid << 32u) | (ll & EPS64), hh + ((lh >> 32u) << 1u) + (mid >> 32u)));
}
fn gf64_sbox(x: u64) -> u64 {
let x2 = gf64_sqr(x);
let x4 = gf64_sqr(x2);
let x6 = gf64_mul(x4, x2);
return gf64_mul(x6, x);
}
fn gf64_canon(a: u64) -> u64 {
return a - select(0lu, P64, a >= P64);
}
// 4x4 MDS circ(2, 3, 1, 1) on one chunk, results left unreduced.
fn mds4(x0: u64, x1: u64, x2: u64, x3: u64) -> array<Acc, 4> {
let t01 = acc_add(Acc(x0, 0u), x1);
let t23 = acc_add(Acc(x2, 0u), x3);
let t0123 = acc_add2(t01, t23);
let t01123 = acc_add(t0123, x1);
let t01233 = acc_add(t0123, x3);
return array<Acc, 4>(
acc_add2(t01123, t01),
acc_add2(t01123, acc_add(Acc(x2, 0u), x2)),
acc_add2(t01233, t23),
acc_add2(t01233, acc_add(Acc(x0, 0u), x0))
);
}
// External linear layer: 4x4 MDS on each chunk, then circulant sums, plus the
// next round's constants. Additions are accumulated unreduced (at most 27
// carries) and folded once per output.
fn ext_layer64(state: ptr<function, array<u64, 12>>, rc: array<u64, 12>) {
var y: array<Acc, 12>;
for (var chunk = 0u; chunk < 3u; chunk++) {
let o = chunk * 4u;
let m = mds4((*state)[o], (*state)[o + 1u], (*state)[o + 2u], (*state)[o + 3u]);
y[o] = m[0];
y[o + 1u] = m[1];
y[o + 2u] = m[2];
y[o + 3u] = m[3];
}
for (var k = 0u; k < 4u; k++) {
let s = acc_add2(acc_add2(y[k], y[k + 4u]), y[k + 8u]);
(*state)[k] = acc_fold(acc_add(acc_add2(y[k], s), rc[k]));
(*state)[k + 4u] = acc_fold(acc_add(acc_add2(y[k + 4u], s), rc[k + 4u]));
(*state)[k + 8u] = acc_fold(acc_add(acc_add2(y[k + 8u], s), rc[k + 8u]));
}
}
// Internal linear layer: diagonal matrix plus full sum, plus the next round's
// constant on element 0 (the S-box output, which is summed last).
fn int_layer64(state: ptr<function, array<u64, 12>>, rc0: u64) {
let s12 = acc_add(Acc((*state)[1], 0u), (*state)[2]);
let s34 = acc_add(Acc((*state)[3], 0u), (*state)[4]);
let s56 = acc_add(Acc((*state)[5], 0u), (*state)[6]);
let s78 = acc_add(Acc((*state)[7], 0u), (*state)[8]);
let s910 = acc_add(Acc((*state)[9], 0u), (*state)[10]);
let s1234 = acc_add2(s12, s34);
let s5678 = acc_add2(s56, s78);
let s91011 = acc_add(s910, (*state)[11]);
let sum = acc_add(acc_add2(acc_add2(s1234, s5678), s91011), (*state)[0]);
(*state)[0] = gf64_mul_add((*state)[0], MDS_DIAG[0], acc_add(sum, rc0));
(*state)[1] = gf64_mul_add((*state)[1], MDS_DIAG[1], sum);
(*state)[2] = gf64_mul_add((*state)[2], MDS_DIAG[2], sum);
(*state)[3] = gf64_mul_add((*state)[3], MDS_DIAG[3], sum);
(*state)[4] = gf64_mul_add((*state)[4], MDS_DIAG[4], sum);
(*state)[5] = gf64_mul_add((*state)[5], MDS_DIAG[5], sum);
(*state)[6] = gf64_mul_add((*state)[6], MDS_DIAG[6], sum);
(*state)[7] = gf64_mul_add((*state)[7], MDS_DIAG[7], sum);
(*state)[8] = gf64_mul_add((*state)[8], MDS_DIAG[8], sum);
(*state)[9] = gf64_mul_add((*state)[9], MDS_DIAG[9], sum);
(*state)[10] = gf64_mul_add((*state)[10], MDS_DIAG[10], sum);
(*state)[11] = gf64_mul_add((*state)[11], MDS_DIAG[11], sum);
}
fn sbox_lanes(state: ptr<function, array<u64, 12>>, lanes: u32) {
for (var i = 0u; i < lanes; i++) {
(*state)[i] = gf64_sbox((*state)[i]);
}
}
fn add_rc(state: ptr<function, array<u64, 12>>, rc: array<u64, 12>) {
for (var i = 0u; i < 12u; i++) {
(*state)[i] = gf64_add((*state)[i], rc[i]);
}
}
const RC_ZERO: array<u64, 12> = array<u64, 12>(0lu, 0lu, 0lu, 0lu, 0lu, 0lu, 0lu, 0lu, 0lu, 0lu, 0lu, 0lu);
// Constants added by each external layer, which feeds the S-box that follows it:
// entries 0..3 precede the initial rounds, entry 4 carries the first internal
// round's constant on element 0, entries 5..7 precede terminal rounds 1..3.
const RC_EXT: array<array<u64, 12>, 9> = array<array<u64, 12>, 9>(
RC_INITIAL[0], RC_INITIAL[1], RC_INITIAL[2], RC_INITIAL[3],
array<u64, 12>(RC_INTERNAL[0], 0lu, 0lu, 0lu, 0lu, 0lu, 0lu, 0lu, 0lu, 0lu, 0lu, 0lu),
RC_TERMINAL[1], RC_TERMINAL[2], RC_TERMINAL[3], RC_ZERO
);
// One loop drives all 30 rounds so each layer is emitted once: step 0 is the
// initial external layer, steps 1..4 and 27..30 are external rounds, steps
// 5..26 are internal rounds. Round constants are added by the linear layer
// preceding each S-box (the last internal layer is followed by the first
// terminal round's constants).
fn permute64(state: ptr<function, array<u64, 12>>) {
for (var k = 0u; k < 31u; k++) {
let is_ext = k < 5u || k > 26u;
if (k > 0u) {
sbox_lanes(state, select(1u, 12u, is_ext));
}
if (is_ext) {
ext_layer64(state, RC_EXT[select(k, k - 22u, k > 26u)]);
} else {
int_layer64(state, RC_INTERNAL[k - 4u]);
if (k == 26u) {
add_rc(state, RC_TERMINAL[0]);
}
}
}
}
fn bswap32(v: u32) -> u32 {
return ((v & 0xFFu) << 24u) | ((v & 0xFF00u) << 8u) | ((v >> 8u) & 0xFF00u) | (v >> 24u);
}
@compute @workgroup_size(256)
fn mining_main(@builtin(global_invocation_id) global_id: vec3<u32>) {
if (atomicLoad(&results[0]) != 0u) {
return;
}
let thread_id = global_id.x;
let total_threads = dispatch_config[0];
let nonces_per_thread = dispatch_config[1];
let total_nonces = dispatch_config[2];
if (thread_id >= total_threads) {
return;
}
let base_index = thread_id * nonces_per_thread;
// Hoist uniform storage reads out of the nonce loop
var mid: array<u64, 12>;
for (var i = 0u; i < 12u; i++) {
mid[i] = (u64(midstate[2u * i + 1u]) << 32u) | u64(midstate[2u * i]);
}
var tgt: array<u32, 16>;
for (var i = 0u; i < 16u; i++) {
tgt[i] = difficulty_target[i];
}
var nonce_base: array<u32, 16>;
for (var i = 0u; i < 16u; i++) {
nonce_base[i] = start_nonce[i];
}
for (var j = 0u; j < nonces_per_thread; j = j + 1u) {
let logical_index = base_index + j;
if (logical_index >= total_nonces) {
break;
}
if (j > 0u && atomicLoad(&results[0]) != 0u) {
return;
}
// The host guarantees a batch never carries into the high nonce half
// (limbs 8..15), so only the low 256 bits are incremented here.
var current_nonce: array<u32, 16>;
let val0 = nonce_base[0];
let sum0 = val0 + logical_index;
current_nonce[0] = sum0;
var carry = select(0u, 1u, sum0 < val0);
for (var i = 1u; i < 8u; i++) {
let val = nonce_base[i];
let sum = val + carry;
current_nonce[i] = sum;
carry = select(0u, 1u, sum < val);
}
for (var i = 8u; i < 16u; i++) {
current_nonce[i] = nonce_base[i];
}
// Resume the sponge from the precomputed midstate: absorb the low
// nonce half, pad, squeeze twice (3 permutations instead of 5).
var st = mid;
for (var i = 0u; i < 8u; i++) {
st[i] = gf64_add(st[i], u64(bswap32(current_nonce[7u - i])));
}
// Squeeze-and-compare phases share one inlined permutation: phase 0
// pads after absorbing, phase 1 yields the most significant 256 bits of
// the hash, which decide hash-vs-target on their own unless they exactly
// equal the target's high half, and only candidates run phase 2 for the
// low half. Byte-swapped hash words are produced on demand.
var hash_le: array<u32, 16>;
var cmp = 0u;
var below = false;
for (var phase = 0u; phase < 3u; phase++) {
permute64(&st);
if (phase == 0u) {
st[0] = gf64_add(st[0], 1lu);
st[1] = gf64_add(st[1], 1lu);
continue;
}
var words: array<u32, 8>;
for (var i = 0u; i < 4u; i++) {
let c = gf64_canon(st[i]);
words[2u * i] = bswap32(u32(c & EPS64));
words[2u * i + 1u] = bswap32(u32(c >> 32u));
}
let base = select(15u, 7u, phase == 2u);
for (var i = 0u; i < 8u; i++) {
hash_le[base - i] = words[i];
}
if (phase == 1u) {
for (var i = 0u; i < 8u; i++) {
let h = words[i];
let t = tgt[15u - i];
if (h != t) {
cmp = select(2u, 1u, h > t);
break;
}
}
if (cmp == 1u) {
break;
}
} else {
below = cmp == 2u;
if (!below) {
for (var i = 0u; i < 8u; i++) {
let h = words[i];
let t = tgt[7u - i];
if (h != t) {
below = h < t;
break;
}
}
}
}
}
if (cmp == 1u) {
continue;
}
if (below) {
if (atomicExchange(&results[0], 1u) == 0u) {
for (var i = 0u; i < 16u; i++) {
atomicStore(&results[1u + i], current_nonce[i]);
atomicStore(&results[17u + i], hash_le[i]);
}
}
return;
}
}
}
// ---------------------------------------------------------------------------
// Compatibility layer: same API as mining.wgsl, backed by the u64 core above.
// Only used by the component test harness; the mining kernel never calls it.
// All outputs are canonical, matching the reference implementation.
// ---------------------------------------------------------------------------
struct GoldilocksField {
limb0: u32,
limb1: u32,
}
const INTERNAL_CONSTANTS: array<array<u32, 2>, 22> = array<array<u32, 2>, 22>(
array<u32, 2>(2018170979u, 2549578122u),
array<u32, 2>(794875120u, 3520249608u),
array<u32, 2>(2677723654u, 1772320679u),
array<u32, 2>(2743438884u, 2849007878u),
array<u32, 2>(518907317u, 693269760u),
array<u32, 2>(293328710u, 1484055617u),
array<u32, 2>(2834138828u, 2315799483u),
array<u32, 2>(1558078501u, 1039128420u),
array<u32, 2>(2266808631u, 966316006u),
array<u32, 2>(3393728842u, 1045622667u),
array<u32, 2>(2245828300u, 2521440415u),
array<u32, 2>(751064958u, 1070374632u),
array<u32, 2>(3490278765u, 2390340773u),
array<u32, 2>(3526960470u, 2224174634u),
array<u32, 2>(639988950u, 4000511088u),
array<u32, 2>(1839350858u, 504240201u),
array<u32, 2>(559852230u, 255489215u),
array<u32, 2>(2713771731u, 453385078u),
array<u32, 2>(1745082278u, 422331096u),
array<u32, 2>(2544763488u, 4141129721u),
array<u32, 2>(2700752774u, 1052996327u),
array<u32, 2>(4063512019u, 1429786100u)
);
const INITIAL_EXTERNAL_CONSTANTS: array<array<array<u32, 2>, 12>, 4> = array<array<array<u32, 2>, 12>, 4>(
array<array<u32, 2>, 12>(
array<u32, 2>(2539329031u, 3221415792u),
array<u32, 2>(4262746426u, 3164936845u),
array<u32, 2>(3883202553u, 1922272763u),
array<u32, 2>(3761386668u, 3841130025u),
array<u32, 2>(1411081289u, 3588274735u),
array<u32, 2>(4090250945u, 3962812520u),
array<u32, 2>(1100826458u, 1215155029u),
array<u32, 2>(1489773809u, 1813820067u),
array<u32, 2>(2585015995u, 3824356688u),
array<u32, 2>(2378857513u, 3651555078u),
array<u32, 2>(2864423342u, 3852156759u),
array<u32, 2>(1531416540u, 708695120u)
),
array<array<u32, 2>, 12>(
array<u32, 2>(1987505445u, 2913073372u),
array<u32, 2>(1426707734u, 655469195u),
array<u32, 2>(3385403543u, 1256631504u),
array<u32, 2>(1381422714u, 1458257259u),
array<u32, 2>(2453402910u, 528129365u),
array<u32, 2>(964329320u, 905986685u),
array<u32, 2>(1534247888u, 3842469367u),
array<u32, 2>(744525997u, 4241857185u),
array<u32, 2>(1756723870u, 3448331916u),
array<u32, 2>(3610291774u, 1105166073u),
array<u32, 2>(2596181885u, 3997051784u),
array<u32, 2>(3199845381u, 3533420525u)
),
array<array<u32, 2>, 12>(
array<u32, 2>(4127777666u, 2103183598u),
array<u32, 2>(2867888172u, 2309916828u),
array<u32, 2>(1831532055u, 3009056407u),
array<u32, 2>(2947502451u, 3675530062u),
array<u32, 2>(3565886616u, 2029012066u),
array<u32, 2>(3833391242u, 642945968u),
array<u32, 2>(1773785903u, 2577032347u),
array<u32, 2>(1770914259u, 1689297286u),
array<u32, 2>(3752758200u, 3993707216u),
array<u32, 2>(3389302766u, 1339375184u),
array<u32, 2>(2180141127u, 1466089441u),
array<u32, 2>(3199591357u, 4111832034u)
),
array<array<u32, 2>, 12>(
array<u32, 2>(1625498743u, 509487959u),
array<u32, 2>(4188712685u, 1646551713u),
array<u32, 2>(3451003566u, 2854767422u),
array<u32, 2>(1412166652u, 1674110767u),
array<u32, 2>(3410212320u, 1000704202u),
array<u32, 2>(3381743837u, 602777331u),
array<u32, 2>(3131873882u, 2866003620u),
array<u32, 2>(2610174026u, 3923414377u),
array<u32, 2>(3644719692u, 3450945356u),
array<u32, 2>(1458984419u, 2418851081u),
array<u32, 2>(3344519983u, 1531855103u),
array<u32, 2>(2721413879u, 3732495392u)
)
);
const TERMINAL_EXTERNAL_CONSTANTS: array<array<array<u32, 2>, 12>, 4> = array<array<array<u32, 2>, 12>, 4>(
array<array<u32, 2>, 12>(
array<u32, 2>(3773451374u, 2071119161u),
array<u32, 2>(3805190518u, 340095962u),
array<u32, 2>(2402679944u, 2149591222u),
array<u32, 2>(743434178u, 1832305922u),
array<u32, 2>(2847530739u, 2718290175u),
array<u32, 2>(514243119u, 4142392203u),
array<u32, 2>(3844443492u, 888639642u),
array<u32, 2>(2008645578u, 2957397405u),
array<u32, 2>(3732799654u, 1692252629u),
array<u32, 2>(664231319u, 248567644u),
array<u32, 2>(287781771u, 482031345u),
array<u32, 2>(3486561978u, 1718871301u)
),
array<array<u32, 2>, 12>(
array<u32, 2>(814165505u, 1616875560u),
array<u32, 2>(2123759183u, 1070984082u),
array<u32, 2>(2722916813u, 3893372341u),
array<u32, 2>(3726899022u, 4157656693u),
array<u32, 2>(2824360073u, 4086907574u),
array<u32, 2>(4155973110u, 1837140488u),
array<u32, 2>(2297731723u, 4169165669u),
array<u32, 2>(707924090u, 1474243980u),
array<u32, 2>(1298483757u, 384287239u),
array<u32, 2>(4243798069u, 557703745u),
array<u32, 2>(1510569718u, 2968696976u),
array<u32, 2>(3174388759u, 3638808363u)
),
array<array<u32, 2>, 12>(
array<u32, 2>(2044277519u, 3835193622u),
array<u32, 2>(2743212840u, 1983595986u),
array<u32, 2>(3943309069u, 151568229u),
array<u32, 2>(424355060u, 1989274413u),
array<u32, 2>(867046322u, 239293714u),
array<u32, 2>(4230997871u, 2479068123u),
array<u32, 2>(1565052394u, 2566260552u),
array<u32, 2>(815274432u, 3822673712u),
array<u32, 2>(1051683535u, 519405993u),
array<u32, 2>(2687564964u, 186958263u),
array<u32, 2>(1450226471u, 1648586942u),
array<u32, 2>(1511122054u, 1595811937u)
),
array<array<u32, 2>, 12>(
array<u32, 2>(2426274859u, 4261676319u),
array<u32, 2>(1207777283u, 1918363057u),
array<u32, 2>(3090099832u, 3870167883u),
array<u32, 2>(4101522705u, 1460796764u),
array<u32, 2>(201900220u, 4164567654u),
array<u32, 2>(2587682901u, 752404845u),
array<u32, 2>(2967564913u, 2100296475u),
array<u32, 2>(3404347409u, 2242778408u),
array<u32, 2>(3350048952u, 1386431957u),
array<u32, 2>(4093308564u, 1347177553u),
array<u32, 2>(2633812729u, 3169012324u),
array<u32, 2>(1727753673u, 3768793234u)
)
);
fn gf_pack(g: GoldilocksField) -> u64 {
return (u64(g.limb1) << 32u) | u64(g.limb0);
}
fn gf_unpack(v: u64) -> GoldilocksField {
let c = gf64_canon(v);
return GoldilocksField(u32(c & EPS64), u32(c >> 32u));
}
fn gf_from_limbs(l0: u32, l1: u32) -> GoldilocksField {
return GoldilocksField(l0, l1);
}
fn gf_zero() -> GoldilocksField {
return GoldilocksField(0u, 0u);
}
fn gf_one() -> GoldilocksField {
return GoldilocksField(1u, 0u);
}
fn gf_from_u32(val: u32) -> GoldilocksField {
return GoldilocksField(val, 0u);
}
fn gf_from_u64_parts(low: u32, high: u32) -> GoldilocksField {
return gf_unpack((u64(high) << 32u) | u64(low));
}
fn gf_from_const(val: array<u32, 2>) -> GoldilocksField {
return gf_from_u64_parts(val[0], val[1]);
}
fn gf_add(a: GoldilocksField, b: GoldilocksField) -> GoldilocksField {
return gf_unpack(gf64_add(gf_pack(a), gf_pack(b)));
}
fn gf_mul(a: GoldilocksField, b: GoldilocksField) -> GoldilocksField {
return gf_unpack(gf64_mul(gf_pack(a), gf_pack(b)));
}
fn sbox(x: GoldilocksField) -> GoldilocksField {
return gf_unpack(gf64_sbox(gf_pack(x)));
}
fn state_pack(state: ptr<function, array<GoldilocksField, 12>>, out: ptr<function, array<u64, 12>>) {
for (var i = 0u; i < 12u; i++) {
(*out)[i] = gf_pack((*state)[i]);
}
}
fn state_unpack(v: ptr<function, array<u64, 12>>, state: ptr<function, array<GoldilocksField, 12>>) {
for (var i = 0u; i < 12u; i++) {
(*state)[i] = gf_unpack((*v)[i]);
}
}
fn external_linear_layer(state: ptr<function, array<GoldilocksField, 12>>) {
var st: array<u64, 12>;
state_pack(state, &st);
ext_layer64(&st, RC_ZERO);
state_unpack(&st, state);
}
fn internal_linear_layer(state: ptr<function, array<GoldilocksField, 12>>) {
var st: array<u64, 12>;
state_pack(state, &st);
int_layer64(&st, 0lu);
state_unpack(&st, state);
}
fn poseidon2_permute(state: ptr<function, array<GoldilocksField, 12>>) {
var st: array<u64, 12>;
state_pack(state, &st);
permute64(&st);
state_unpack(&st, state);
}
fn bytes_to_field_elements(input: array<u32, 24>) -> array<GoldilocksField, 25> {
var felts: array<GoldilocksField, 25>;
for (var i = 0u; i < 24u; i++) {
felts[i] = gf_from_u32(input[i]);
}
felts[24] = gf_one();
return felts;
}
fn field_elements_to_bytes(felts: array<GoldilocksField, 4>) -> array<u32, 8> {
var result: array<u32, 8>;
for (var i = 0u; i < 4u; i++) {
result[i * 2u] = felts[i].limb0;
result[i * 2u + 1u] = felts[i].limb1;
}
return result;
}
fn poseidon2_hash_squeeze_twice(input: array<u32, 24>) -> array<u32, 16> {
var st: array<u64, 12>;
for (var i = 0u; i < 12u; i++) {
st[i] = 0lu;
}
for (var chunk = 0u; chunk < 3u; chunk++) {
for (var i = 0u; i < 8u; i++) {
st[i] = gf64_add(st[i], u64(input[chunk * 8u + i]));
}
permute64(&st);
}
st[0] = gf64_add(st[0], 1lu);
st[1] = gf64_add(st[1], 1lu);
permute64(&st);
var result: array<u32, 16>;
for (var i = 0u; i < 4u; i++) {
let c = gf64_canon(st[i]);
result[2u * i] = u32(c & EPS64);
result[2u * i + 1u] = u32(c >> 32u);
}
permute64(&st);
for (var i = 0u; i < 4u; i++) {
let c = gf64_canon(st[i]);
result[8u + 2u * i] = u32(c & EPS64);
result[8u + 2u * i + 1u] = u32(c >> 32u);
}
return result;
}
fn hash_squeeze_twice(input: array<u32, 24>) -> array<u32, 16> {
return poseidon2_hash_squeeze_twice(input);
}

View File

@@ -0,0 +1,116 @@
//! Poseidon2 mining kernels.
//!
//! Same `mining_main` bindings; must stay bit-exact with `pow_core`.
//!
//! - Apple Metal + `SHADER_INT64` → Apple Metal u64 (`mining_u64_apple.wgsl`)
//! - other GPUs + `SHADER_INT64` → native u64 (`mining_u64.wgsl`)
//! - no `SHADER_INT64` → 32-bit fallback (`mining.wgsl`)
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Kernel {
U32,
Default,
Apple,
}
impl Kernel {
pub const fn needs_int64(self) -> bool {
!matches!(self, Self::U32)
}
pub const fn id(self) -> &'static str {
match self {
Self::U32 => "u32",
Self::Default => "u64",
Self::Apple => "u64-apple",
}
}
pub const fn label(self) -> &'static str {
match self {
Self::U32 => "32-bit",
Self::Default => "native-u64",
Self::Apple => "native-u64 Apple Metal",
}
}
pub const fn source(self) -> &'static str {
match self {
Self::U32 => include_str!("mining.wgsl"),
Self::Default => include_str!("mining_u64.wgsl"),
Self::Apple => include_str!("mining_u64_apple.wgsl"),
}
}
pub fn for_adapter(adapter: &wgpu::Adapter) -> Self {
Self::for_adapter_info(&adapter.get_info(), adapter.features())
}
pub fn for_adapter_info(info: &wgpu::AdapterInfo, features: wgpu::Features) -> Self {
if !features.contains(wgpu::Features::SHADER_INT64) {
return Self::U32;
}
if info.backend == wgpu::Backend::Metal {
Self::Apple
} else {
Self::Default
}
}
pub const fn all() -> &'static [Self] {
&[Self::U32, Self::Default, Self::Apple]
}
}
#[cfg(test)]
mod tests {
use super::*;
fn info(backend: wgpu::Backend) -> wgpu::AdapterInfo {
wgpu::AdapterInfo {
name: "test".into(),
vendor: 0,
device: 0,
device_type: wgpu::DeviceType::DiscreteGpu,
driver: String::new(),
driver_info: String::new(),
backend,
}
}
#[test]
fn metal_with_int64_selects_apple() {
assert_eq!(
Kernel::for_adapter_info(&info(wgpu::Backend::Metal), wgpu::Features::SHADER_INT64),
Kernel::Apple
);
}
#[test]
fn vulkan_with_int64_selects_default() {
assert_eq!(
Kernel::for_adapter_info(&info(wgpu::Backend::Vulkan), wgpu::Features::SHADER_INT64),
Kernel::Default
);
}
#[test]
fn dx12_with_int64_selects_default() {
assert_eq!(
Kernel::for_adapter_info(&info(wgpu::Backend::Dx12), wgpu::Features::SHADER_INT64),
Kernel::Default
);
}
#[test]
fn no_int64_falls_back_to_u32() {
assert_eq!(
Kernel::for_adapter_info(&info(wgpu::Backend::Metal), wgpu::Features::empty()),
Kernel::U32
);
assert_eq!(
Kernel::for_adapter_info(&info(wgpu::Backend::Vulkan), wgpu::Features::empty()),
Kernel::U32
);
}
}

View File

@@ -1,11 +1,14 @@
#![deny(rust_2018_idioms)]
#![forbid(unsafe_code)]
#![deny(unsafe_code)]
mod gpu_tiers;
mod kernels;
pub mod end_to_end_tests;
pub mod tests;
pub use kernels::Kernel;
use engine_cpu::{CancelCheck, Candidate, EngineStatus, FoundOrigin, MinerEngine, Range};
use pow_core::{format_hashrate, format_u512, JobContext};
use primitive_types::U512;
@@ -17,6 +20,8 @@ use std::sync::{
/// Represents a single GPU device context.
struct GpuContext {
// lair: per-device metric handles, labelled with the kernel id (quantus/miner#9).
metrics: metrics::DeviceMetrics,
device: wgpu::Device,
queue: wgpu::Queue,
pipeline: wgpu::ComputePipeline,
@@ -27,7 +32,7 @@ struct GpuContext {
#[derive(Clone)]
struct GpuResources {
header_buffer: wgpu::Buffer,
midstate_buffer: wgpu::Buffer,
target_buffer: wgpu::Buffer,
start_nonce_buffer: wgpu::Buffer,
results_buffer: wgpu::Buffer,
@@ -37,29 +42,34 @@ struct GpuResources {
}
pub struct GpuEngine {
engine_id: usize,
contexts: Vec<Arc<GpuContext>>,
device_counter: AtomicUsize,
batch_size: u32,
throttle_ms: u64,
}
// Thread-local storage for consistent GPU device assignment per worker thread
static ENGINE_ID_COUNTER: AtomicUsize = AtomicUsize::new(0);
// Thread-local storage for consistent GPU device assignment per worker thread.
// Entries are tagged with the owning engine's id so resources created for one
// GpuEngine (and its devices) are never used with another (issue seen in benches
// where multiple engines exist in one process).
thread_local! {
static ASSIGNED_GPU_DEVICE: RefCell<Option<usize>> = const { RefCell::new(None) };
static WORKER_RESOURCES: RefCell<Option<GpuResources>> = const { RefCell::new(None) };
/// Set to true when this worker's GPU device is lost/unresponsive.
/// Once set, the worker will immediately return Cancelled on any search attempt.
static DEVICE_LOST: RefCell<bool> = const { RefCell::new(false) };
static ASSIGNED_GPU_DEVICE: RefCell<Option<(usize, usize)>> = const { RefCell::new(None) };
static WORKER_RESOURCES: RefCell<Option<(usize, GpuResources)>> = const { RefCell::new(None) };
/// Engine id whose GPU device was lost/unresponsive for this worker thread.
static DEVICE_LOST: RefCell<Option<usize>> = const { RefCell::new(None) };
}
impl GpuContext {
fn create_resources(&self) -> GpuResources {
let bind_group_layout = self.pipeline.get_bind_group_layout(0);
// Header: 8 u32s
let header_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Header Buffer"),
size: 32,
// Midstate: 12 felts as LE u32 pairs
let midstate_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Midstate Buffer"),
size: 96,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
@@ -116,7 +126,7 @@ impl GpuContext {
},
wgpu::BindGroupEntry {
binding: 1,
resource: header_buffer.as_entire_binding(),
resource: midstate_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
@@ -134,7 +144,7 @@ impl GpuContext {
});
GpuResources {
header_buffer,
midstate_buffer,
target_buffer,
start_nonce_buffer,
results_buffer,
@@ -145,6 +155,26 @@ impl GpuContext {
}
}
/// Create the mining shader module without naga's runtime bounds checks and
/// loop bounding (~9% faster kernels).
///
/// SAFETY: the sources are the static mining kernels compiled into this binary;
/// every buffer access is a constant-bounded loop index into fixed-size
/// bindings the engine itself allocates, and all loops have static bounds
/// (verified by the component test suites against every kernel path).
#[allow(unsafe_code)]
fn create_trusted_shader(device: &wgpu::Device, shader_source: &str) -> wgpu::ShaderModule {
unsafe {
device.create_shader_module_trusted(
wgpu::ShaderModuleDescriptor {
label: Some("Mining Shader"),
source: wgpu::ShaderSource::Wgsl(shader_source.into()),
},
wgpu::ShaderRuntimeChecks::unchecked(),
)
}
}
/// Rank backends for mining: native compute APIs first.
fn backend_rank(backend: wgpu::Backend) -> u8 {
match backend {
@@ -332,11 +362,17 @@ impl GpuEngine {
);
log::debug!(target: "gpu_engine", "Adapter {i} raw info: {info:?}");
let kernel = Kernel::for_adapter(&adapter);
// Try to initialize this adapter with a proper timeout.
// If the driver hangs, we'll skip this adapter after the timeout.
let device_future = adapter.request_device(&wgpu::DeviceDescriptor {
label: Some("Mining Device"),
required_features: wgpu::Features::empty(),
required_features: if kernel.needs_int64() {
wgpu::Features::SHADER_INT64
} else {
wgpu::Features::empty()
},
required_limits: wgpu::Limits::default(),
memory_hints: Default::default(),
..Default::default()
@@ -379,11 +415,14 @@ impl GpuEngine {
// Shader and pipeline creation are synchronous - can't timeout, but usually fast
let pipeline_start = std::time::Instant::now();
let shader_source = include_str!("mining.wgsl");
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Mining Shader"),
source: wgpu::ShaderSource::Wgsl(shader_source.into()),
});
log::info!(
target: "gpu_engine",
"GPU device {i} ({}) using {} [{}]",
info.name,
kernel.label(),
kernel.id()
);
let shader = create_trusted_shader(&device, kernel.source());
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("Mining Pipeline"),
@@ -406,6 +445,7 @@ impl GpuEngine {
initialized.push(InitializedGpu {
context: Arc::new(GpuContext {
metrics: metrics::DeviceMetrics::new(i, kernel.id()),
device,
queue,
pipeline,
@@ -451,6 +491,7 @@ impl GpuEngine {
);
Ok(Self {
engine_id: ENGINE_ID_COUNTER.fetch_add(1, Ordering::SeqCst),
contexts,
device_counter: AtomicUsize::new(0),
batch_size,
@@ -464,11 +505,20 @@ impl GpuEngine {
}
/// Explicitly clear thread-local GPU resources.
/// Call this before thread exit to avoid TLS destruction order issues with wgpu.
/// Call this before dropping a `GpuEngine` (or before thread exit) so
/// buffers are destroyed while the device is still alive — otherwise the
/// next engine on this thread can reuse dead buffer IDs and panic with
/// `Buffer[…] does not exist`.
pub fn clear_worker_resources() {
WORKER_RESOURCES.with(|resources| {
*resources.borrow_mut() = None;
});
ASSIGNED_GPU_DEVICE.with(|assigned| {
*assigned.borrow_mut() = None;
});
DEVICE_LOST.with(|lost| {
*lost.borrow_mut() = None;
});
}
}
@@ -496,8 +546,8 @@ impl MinerEngine for GpuEngine {
return EngineStatus::Exhausted { hash_count: 0 };
}
// Check if this worker's GPU device was previously lost
let device_is_lost = DEVICE_LOST.with(|lost| *lost.borrow());
// Check if this worker's GPU device was previously lost (for this engine)
let device_is_lost = DEVICE_LOST.with(|lost| *lost.borrow() == Some(self.engine_id));
if device_is_lost {
// Device was lost in a previous call - signal worker should exit
return EngineStatus::DeviceLost { hash_count: 0 };
@@ -516,50 +566,42 @@ impl MinerEngine for GpuEngine {
// Use thread-local assignment for consistent worker-to-GPU mapping
let device_index = ASSIGNED_GPU_DEVICE.with(|assigned| {
let mut assigned_ref = assigned.borrow_mut();
if let Some(index) = *assigned_ref {
index
} else {
let index = if self.contexts.len() == 1 {
0
} else {
self.device_counter.fetch_add(1, Ordering::SeqCst) % self.contexts.len()
};
*assigned_ref = Some(index);
log::info!(
target: "gpu_engine",
"Worker thread assigned to GPU device {} (of {} total devices)",
index,
self.contexts.len()
);
index
match *assigned_ref {
Some((engine_id, index)) if engine_id == self.engine_id => index,
_ => {
let index = if self.contexts.len() == 1 {
0
} else {
self.device_counter.fetch_add(1, Ordering::SeqCst) % self.contexts.len()
};
*assigned_ref = Some((self.engine_id, index));
log::info!(
target: "gpu_engine",
"Worker thread assigned to GPU device {} (of {} total devices)",
index,
self.contexts.len()
);
index
}
}
});
let gpu_ctx = &self.contexts[device_index];
// Ensure resources are initialized for this thread
WORKER_RESOURCES.with(|resources_cell| {
// Ensure resources are initialized for this thread and belong to this engine
let resources = WORKER_RESOURCES.with(|resources_cell| {
let mut resources = resources_cell.borrow_mut();
if resources.is_none() {
*resources = Some(gpu_ctx.create_resources());
match &*resources {
Some((engine_id, res)) if *engine_id == self.engine_id => res.clone(),
_ => {
let res = gpu_ctx.create_resources();
*resources = Some((self.engine_id, res.clone()));
res
}
}
});
let resources = WORKER_RESOURCES
.with(|resources_cell| resources_cell.borrow().as_ref().unwrap().clone());
// Pre-convert header and target (only needs to be done once per job)
let mut header_u32s = [0u32; 8];
for (i, item) in header_u32s.iter_mut().enumerate() {
let chunk = &ctx.header[i * 4..(i + 1) * 4];
*item = u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
}
gpu_ctx.queue.write_buffer(
&resources.header_buffer,
0,
bytemuck::cast_slice(&header_u32s),
);
// Pre-convert target (only needs to be done once per job)
let target_bytes = ctx.target.to_little_endian();
let mut target_u32s = [0u32; 16];
for i in 0..16 {
@@ -576,6 +618,9 @@ impl MinerEngine for GpuEngine {
let mut total_hashes: u64 = 0;
let mut current_start = range.start;
let mut batch_num = 0u64;
// lair: hashes of the most recent batch; if the job turns out to have
// been superseded while it ran, that batch was wasted work.
let mut last_batch_hashes: u64 = 0;
log::info!(
target: "gpu_engine",
@@ -590,6 +635,7 @@ impl MinerEngine for GpuEngine {
while current_start <= range.end {
// Check for cancellation at host level BEFORE starting each batch
if cancel.is_cancelled() {
gpu_ctx.metrics.record_stale_hashes(last_batch_hashes); // lair
let elapsed = search_start.elapsed();
let hash_rate = total_hashes as f64 / elapsed.as_secs_f64();
log::info!(
@@ -606,22 +652,27 @@ impl MinerEngine for GpuEngine {
};
}
// Calculate batch range
// Calculate batch range. The batch is additionally clamped so nonce
// increments never carry into the high 256 bits, which the midstate
// precompute (and the kernels) rely on.
let remaining = range
.end
.saturating_sub(current_start)
.saturating_add(U512::one());
let headroom =
(U512::one() << 256) - (current_start & ((U512::one() << 256) - U512::one()));
let cap = remaining.min(headroom);
let batch_size_u512 = U512::from(self.batch_size);
let this_batch_size: u32 = if remaining > batch_size_u512 {
let this_batch_size: u32 = if cap > batch_size_u512 {
self.batch_size
} else {
// remaining fits in u32 since it's <= batch_size which is u32
remaining.low_u32()
// cap fits in u32 since it's <= batch_size which is u32
cap.low_u32()
};
// Run single batch
let batch_result =
run_single_batch(gpu_ctx, &resources, current_start, this_batch_size);
run_single_batch(gpu_ctx, &resources, ctx, current_start, this_batch_size);
match batch_result {
BatchResult::Found {
@@ -652,11 +703,13 @@ impl MinerEngine for GpuEngine {
}
BatchResult::NotFound { hash_count } => {
total_hashes += hash_count;
last_batch_hashes = hash_count; // lair
}
BatchResult::DeviceLost => {
// GPU device is lost/unresponsive - mark as permanently dead
// and clear resources to prevent "buffer already mapped" panics
DEVICE_LOST.with(|lost| *lost.borrow_mut() = true);
gpu_ctx.metrics.record_device_lost(); // lair
// GPU device is lost/unresponsive - mark as permanently dead
// and clear resources to prevent "buffer already mapped" panics
DEVICE_LOST.with(|lost| *lost.borrow_mut() = Some(self.engine_id));
WORKER_RESOURCES.with(|res| *res.borrow_mut() = None);
log::error!(
@@ -745,10 +798,12 @@ enum BatchResult {
fn run_single_batch(
gpu_ctx: &GpuContext,
resources: &GpuResources,
ctx: &JobContext,
batch_start: U512,
batch_size: u32,
) -> BatchResult {
// Calculate dispatch configuration for this batch
let batch_start_at = std::time::Instant::now(); // lair: batch phase timing
// Calculate dispatch configuration for this batch
let threads_per_workgroup = 256u32;
let limits = gpu_ctx.device.limits();
let max_workgroups = limits.max_compute_workgroups_per_dimension;
@@ -777,6 +832,20 @@ fn run_single_batch(
.queue
.write_buffer(&resources.start_nonce_buffer, 0, &start_nonce_bytes);
// Precompute the sponge midstate for this batch (header + high nonce half)
let nonce_be = batch_start.to_big_endian();
let midstate = pow_core::mining_midstate(ctx.header, nonce_be[..32].try_into().unwrap());
let mut midstate_u32s = [0u32; 24];
for (i, felt) in midstate.iter().enumerate() {
midstate_u32s[2 * i] = *felt as u32;
midstate_u32s[2 * i + 1] = (*felt >> 32) as u32;
}
gpu_ctx.queue.write_buffer(
&resources.midstate_buffer,
0,
bytemuck::cast_slice(&midstate_u32s),
);
// Reset results buffer
const RESULTS_SIZE: usize = (1 + 16 + 16) * 4;
const ZEROS: [u8; RESULTS_SIZE] = [0; RESULTS_SIZE];
@@ -806,6 +875,7 @@ fn run_single_batch(
);
gpu_ctx.queue.submit(Some(encoder.finish()));
let submitted_at = std::time::Instant::now(); // lair
// Wait for GPU to complete (blocking)
let buffer_slice = resources.staging_buffer.slice(..);
@@ -852,6 +922,12 @@ fn run_single_batch(
// Only reach here if final_status == 1 (success), buffer is mapped
debug_assert_eq!(final_status, 1);
// lair: gpu = submit to mapped; host = the rest of the batch so far plus
// the readback below, which is small and constant.
let gpu_time = submitted_at.elapsed();
gpu_ctx
.metrics
.observe_batch(gpu_time, batch_start_at.elapsed() - gpu_time);
// Read results
let data = buffer_slice.get_mapped_range();
@@ -880,6 +956,8 @@ fn run_single_batch(
drop(data);
resources.staging_buffer.unmap();
gpu_ctx.metrics.record_hashes(hashes_computed); // lair
gpu_ctx.metrics.record_solution(); // lair
return BatchResult::Found {
candidate: Candidate { nonce, work, hash },
hash_count: hashes_computed,
@@ -889,6 +967,7 @@ fn run_single_batch(
drop(data);
resources.staging_buffer.unmap();
gpu_ctx.metrics.record_hashes(dispatched_nonces); // lair
BatchResult::NotFound {
hash_count: dispatched_nonces,
}

View File

@@ -3,174 +3,130 @@ mod tests;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
run().await
}
async fn run() -> Result<(), Box<dyn std::error::Error>> {
// Setup GPU device and queue
let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor {
backends: wgpu::Backends::METAL, // Force Metal on Apple
backends: wgpu::Backends::PRIMARY,
..Default::default()
});
let adapter = instance
.request_adapter(&wgpu::RequestAdapterOptions::default())
.await
.unwrap();
let (device, queue) = adapter
.request_device(&wgpu::DeviceDescriptor::default())
.await?;
.expect("no GPU adapter");
println!("Running Poseidon2 GPU Component Tests...\n");
let mut failures = 0usize;
let has_int64 = adapter.features().contains(wgpu::Features::SHADER_INT64);
// Run all component tests
if let Err(e) = tests::test_gf_from_const(&device, &queue).await {
eprintln!("❌ gf_from_const tests failed: {}", e);
for kernel in engine_gpu::Kernel::all() {
if kernel.needs_int64() && !has_int64 {
println!(
"\nSHADER_INT64 not supported on this adapter; skipping {}",
kernel.label()
);
continue;
}
let (device, queue) = adapter
.request_device(&wgpu::DeviceDescriptor {
label: Some(kernel.label()),
required_features: if kernel.needs_int64() {
wgpu::Features::SHADER_INT64
} else {
wgpu::Features::empty()
},
..Default::default()
})
.await?;
failures += run_suite(&device, &queue, kernel.source(), kernel.label()).await;
}
if let Err(e) = tests::test_gf_mul(&device, &queue).await {
eprintln!("❌ gf_mul tests failed: {}", e);
if failures > 0 {
return Err(format!("{failures} test group(s) failed").into());
}
if let Err(e) = tests::test_sbox(&device, &queue).await {
eprintln!("❌ S-box tests failed: {}", e);
}
if let Err(e) = tests::test_mds_matrix(&device, &queue).await {
eprintln!("❌ MDS matrix tests failed: {}", e);
}
if let Err(e) = tests::test_internal_linear_layer(&device, &queue).await {
eprintln!("❌ Internal linear layer tests failed: {}", e);
}
if let Err(e) = tests::test_external_linear_layer(&device, &queue).await {
eprintln!("❌ External linear layer tests failed: {}", e);
}
if let Err(e) = tests::test_poseidon2_initial_external_rounds(&device, &queue).await {
eprintln!("❌ Initial external rounds tests failed: {}", e);
}
if let Err(e) = tests::test_poseidon2_terminal_external_rounds(&device, &queue).await {
eprintln!("❌ Terminal external rounds tests failed: {}", e);
}
if let Err(e) = tests::test_poseidon2_constants_verification(&device, &queue).await {
eprintln!("❌ Constants verification test failed: {}", e);
}
if let Err(e) = tests::test_poseidon2_internal_constants_verification(&device, &queue).await {
eprintln!("❌ Internal constants verification test failed: {}", e);
}
if let Err(e) = tests::test_poseidon2_internal_rounds_only(&device, &queue).await {
eprintln!("❌ Internal rounds only test failed: {}", e);
}
if let Err(e) =
tests::test_poseidon2_terminal_external_constants_verification(&device, &queue).await
{
eprintln!(
"❌ Terminal external constants verification test failed: {}",
e
);
}
if let Err(e) = tests::test_poseidon2_permutation(&device, &queue).await {
eprintln!("❌ Poseidon2 permutation tests failed: {}", e);
}
if let Err(e) = tests::test_bytes_to_field_elements(&device, &queue).await {
eprintln!("❌ Bytes to field elements tests failed: {}", e);
}
if let Err(e) = tests::test_field_elements_to_bytes(&device, &queue).await {
eprintln!("❌ Field elements to bytes tests failed: {}", e);
}
if let Err(e) = tests::test_poseidon2_squeeze_twice(&device, &queue).await {
eprintln!("❌ Poseidon2 squeeze-twice tests failed: {}", e);
}
if let Err(e) = tests::test_hash_squeeze_twice(&device, &queue).await {
eprintln!("❌ Hash squeeze twice tests failed: {}", e);
}
if let Err(e) = end_to_end_tests::test_end_to_end_mining(&device, &queue).await {
eprintln!("❌ End-to-end mining test failed: {}", e);
}
println!("\nAll tests completed!");
if let Err(e) = end_to_end_tests::test_end_to_end_mining(&device, &queue).await {
eprintln!("❌ End-to-end mining test failed: {}", e);
}
println!("\nAll tests completed!");
// generate_correct_wgsl_constants();
Ok(())
}
#[allow(dead_code)]
fn generate_correct_wgsl_constants() {
use qp_poseidon_constants::*;
println!("🔧 Generating correct WGSL constants...");
println!("// Initial external round constants (4 rounds x 12 elements)");
println!("const INITIAL_EXTERNAL_CONSTANTS: array<array<array<u32, 2>, 12>, 4> = array<array<array<u32, 2>, 12>, 4>(");
for (round_idx, round) in POSEIDON2_INITIAL_EXTERNAL_CONSTANTS_RAW.iter().enumerate() {
println!(" array<array<u32, 2>, 12>(");
for (elem_idx, &value) in round.iter().enumerate() {
let low = value as u32;
let high = (value >> 32) as u32;
if elem_idx == 11 {
println!(" array<u32, 2>({}u, {}u)", low, high);
} else {
println!(" array<u32, 2>({}u, {}u),", low, high);
async fn run_suite(
device: &wgpu::Device,
queue: &wgpu::Queue,
shader_src: &str,
label: &str,
) -> usize {
println!("\n==== Running Poseidon2 GPU tests against {label} ====\n");
let mut failures = 0usize;
macro_rules! run {
($name:literal, $fut:expr) => {
if let Err(e) = $fut.await {
eprintln!("{} failed: {}", $name, e);
failures += 1;
}
}
if round_idx == 3 {
println!(" )");
} else {
println!(" ),");
}
};
}
println!(");");
println!("\n// Terminal external round constants (4 rounds x 12 elements)");
println!("const TERMINAL_EXTERNAL_CONSTANTS: array<array<array<u32, 2>, 12>, 4> = array<array<array<u32, 2>, 12>, 4>(");
run!(
"gf_from_const",
tests::test_gf_from_const(device, queue, shader_src)
);
run!("gf_mul", tests::test_gf_mul(device, queue, shader_src));
run!("sbox", tests::test_sbox(device, queue, shader_src));
run!(
"mds matrix",
tests::test_mds_matrix(device, queue, shader_src)
);
run!(
"internal linear layer",
tests::test_internal_linear_layer(device, queue, shader_src)
);
run!(
"external linear layer",
tests::test_external_linear_layer(device, queue, shader_src)
);
run!(
"initial external rounds",
tests::test_poseidon2_initial_external_rounds(device, queue, shader_src)
);
run!(
"terminal external rounds",
tests::test_poseidon2_terminal_external_rounds(device, queue, shader_src)
);
run!(
"constants verification",
tests::test_poseidon2_constants_verification(device, queue, shader_src)
);
run!(
"internal constants verification",
tests::test_poseidon2_internal_constants_verification(device, queue, shader_src)
);
run!(
"internal rounds only",
tests::test_poseidon2_internal_rounds_only(device, queue, shader_src)
);
run!(
"terminal external constants verification",
tests::test_poseidon2_terminal_external_constants_verification(device, queue, shader_src)
);
run!(
"poseidon2 permutation",
tests::test_poseidon2_permutation(device, queue, shader_src)
);
run!(
"bytes to field elements",
tests::test_bytes_to_field_elements(device, queue, shader_src)
);
run!(
"field elements to bytes",
tests::test_field_elements_to_bytes(device, queue, shader_src)
);
run!(
"poseidon2 squeeze-twice",
tests::test_poseidon2_squeeze_twice(device, queue, shader_src)
);
run!(
"hash squeeze twice",
tests::test_hash_squeeze_twice(device, queue, shader_src)
);
run!(
"end-to-end mining",
end_to_end_tests::test_end_to_end_mining(device, queue, shader_src)
);
for (round_idx, round) in POSEIDON2_TERMINAL_EXTERNAL_CONSTANTS_RAW.iter().enumerate() {
println!(" array<array<u32, 2>, 12>(");
for (elem_idx, &value) in round.iter().enumerate() {
let low = value as u32;
let high = (value >> 32) as u32;
if elem_idx == 11 {
println!(" array<u32, 2>({}u, {}u)", low, high);
} else {
println!(" array<u32, 2>({}u, {}u),", low, high);
}
}
if round_idx == 3 {
println!(" )");
} else {
println!(" ),");
}
}
println!(");");
println!("\n// Internal round constants (22 values)");
println!("const INTERNAL_CONSTANTS: array<array<u32, 2>, 22> = array<array<u32, 2>, 22>(");
for (idx, &value) in POSEIDON2_INTERNAL_CONSTANTS_RAW.iter().enumerate() {
let low = value as u32;
let high = (value >> 32) as u32;
if idx == 21 {
println!(" array<u32, 2>({}u, {}u)", low, high);
} else {
println!(" array<u32, 2>({}u, {}u),", low, high);
}
}
println!(");");
failures
}

View File

@@ -1166,6 +1166,7 @@ fn generate_true_internal_only_test_vectors() -> Vec<InternalRoundsTestCase> {
pub async fn test_poseidon2_permutation(
device: &wgpu::Device,
queue: &wgpu::Queue,
shader_src: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let test_vectors = generate_poseidon2_test_vectors();
let total_tests = test_vectors.len();
@@ -1202,7 +1203,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
}}
}}
",
include_str!("mining.wgsl")
shader_src
);
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
@@ -1370,6 +1371,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
pub async fn test_poseidon2_initial_external_rounds(
device: &wgpu::Device,
queue: &wgpu::Queue,
shader_src: &str,
) -> Result<(), Box<dyn std::error::Error>> {
// Test just the initial 4 external rounds
let test_vectors = generate_gf_from_const_test_vectors();
@@ -1415,7 +1417,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
}}
}}
",
include_str!("mining.wgsl")
shader_src
);
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
@@ -1630,6 +1632,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
pub async fn test_poseidon2_terminal_external_rounds(
device: &wgpu::Device,
queue: &wgpu::Queue,
shader_src: &str,
) -> Result<(), Box<dyn std::error::Error>> {
// Test just the terminal 4 external rounds
let _total_tests = 2;
@@ -1674,7 +1677,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
}}
}}
",
include_str!("mining.wgsl")
shader_src
);
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
@@ -1888,6 +1891,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
pub async fn test_gf_from_const(
device: &wgpu::Device,
queue: &wgpu::Queue,
shader_src: &str,
) -> Result<(), Box<dyn std::error::Error>> {
// Test the gf_from_const function that converts constant arrays to GoldilocksField
let test_vectors = generate_gf_from_const_test_vectors();
@@ -1908,7 +1912,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
output_fields[index] = gf_from_const(input_constants[index]);
}}
",
include_str!("mining.wgsl")
shader_src
);
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
@@ -2023,6 +2027,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
pub async fn test_poseidon2_constants_verification(
device: &wgpu::Device,
queue: &wgpu::Queue,
shader_src: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let mut passed_tests = 0;
let mut failed_tests = Vec::new();
@@ -2049,7 +2054,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
}}
}}
",
include_str!("mining.wgsl")
shader_src
);
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
@@ -2160,6 +2165,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
pub async fn test_poseidon2_internal_rounds_only(
device: &wgpu::Device,
queue: &wgpu::Queue,
shader_src: &str,
) -> Result<(), Box<dyn std::error::Error>> {
// Test ONLY the internal rounds (22 rounds) without initial/terminal external rounds
let test_vectors = generate_true_internal_only_test_vectors();
@@ -2209,7 +2215,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
}}
}}
",
include_str!("mining.wgsl")
shader_src
);
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
@@ -2343,6 +2349,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
pub async fn test_poseidon2_internal_constants_verification(
device: &wgpu::Device,
queue: &wgpu::Queue,
shader_src: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let mut passed_tests = 0;
let mut failed_tests = Vec::new();
@@ -2364,7 +2371,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
}}
}}
",
include_str!("mining.wgsl")
shader_src
);
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
@@ -2462,6 +2469,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
pub async fn test_poseidon2_terminal_external_rounds_issue(
device: &wgpu::Device,
queue: &wgpu::Queue,
shader_src: &str,
) -> Result<(), Box<dyn std::error::Error>> {
println!("🔍 Testing terminal external rounds issue for sequential input...");
@@ -2502,7 +2510,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
}}
}}
",
include_str!("mining.wgsl")
shader_src
);
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
@@ -2630,6 +2638,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
pub async fn test_poseidon2_terminal_external_constants_verification(
device: &wgpu::Device,
queue: &wgpu::Queue,
shader_src: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let mut passed_tests = 0;
let mut failed_tests = Vec::new();
@@ -2656,7 +2665,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
}}
}}
",
include_str!("mining.wgsl")
shader_src
);
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
@@ -2764,6 +2773,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
pub async fn test_gf_mul(
device: &wgpu::Device,
queue: &wgpu::Queue,
shader_src: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let test_vectors = generate_gf_mul_test_vectors();
let total_tests = test_vectors.len();
@@ -2786,7 +2796,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
output[i] = gf_mul(input_a[i], input_b[i]);
}}
",
include_str!("mining.wgsl")
shader_src
);
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
@@ -2947,6 +2957,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
pub async fn test_mds_matrix(
device: &wgpu::Device,
queue: &wgpu::Queue,
shader_src: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let test_vectors = generate_mds_test_vectors();
let total_tests = test_vectors.len();
@@ -2955,7 +2966,7 @@ pub async fn test_mds_matrix(
let mut failure_details = Vec::new();
// Read the mining shader source and create a test wrapper
let mining_shader_source = include_str!("mining.wgsl");
let mining_shader_source = shader_src;
let shader_source = format!(
"
{}
@@ -3215,6 +3226,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
pub async fn test_internal_linear_layer(
device: &wgpu::Device,
queue: &wgpu::Queue,
shader_src: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let test_vectors = generate_internal_linear_layer_test_vectors();
let total_tests = test_vectors.len();
@@ -3223,7 +3235,7 @@ pub async fn test_internal_linear_layer(
let mut failure_details = Vec::new();
// Read the mining shader source and create a test wrapper
let mining_shader_source = include_str!("mining.wgsl");
let mining_shader_source = shader_src;
let shader_source = format!(
"
{}
@@ -3448,6 +3460,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
pub async fn test_external_linear_layer(
device: &wgpu::Device,
queue: &wgpu::Queue,
shader_src: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let test_vectors = generate_external_linear_layer_test_vectors();
let total_tests = test_vectors.len();
@@ -3485,7 +3498,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
}}
}}
",
include_str!("mining.wgsl")
shader_src
);
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
@@ -3625,6 +3638,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
pub async fn test_sbox(
device: &wgpu::Device,
queue: &wgpu::Queue,
shader_src: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let test_vectors = generate_sbox_test_vectors();
let total_tests = test_vectors.len();
@@ -3662,7 +3676,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
output_data[index] = result;
}}
",
include_str!("mining.wgsl"),
shader_src,
batch.len()
);
@@ -3807,6 +3821,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
pub async fn test_poseidon2_squeeze_twice(
device: &wgpu::Device,
queue: &wgpu::Queue,
shader_src: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let test_vectors = generate_poseidon2_squeeze_twice_test_vectors();
let total_tests = test_vectors.len();
@@ -3842,7 +3857,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
}}
}}
",
include_str!("mining.wgsl")
shader_src
);
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
@@ -3985,6 +4000,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
pub async fn test_bytes_to_field_elements(
device: &wgpu::Device,
queue: &wgpu::Queue,
shader_src: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let test_vectors = generate_bytes_to_field_test_vectors();
let total_tests = test_vectors.len();
@@ -4020,7 +4036,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
}}
}}
",
include_str!("mining.wgsl")
shader_src
);
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
@@ -4168,6 +4184,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
pub async fn test_field_elements_to_bytes(
device: &wgpu::Device,
queue: &wgpu::Queue,
shader_src: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let test_vectors = generate_field_to_bytes_test_vectors();
let total_tests = test_vectors.len();
@@ -4203,7 +4220,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
}}
}}
",
include_str!("mining.wgsl")
shader_src
);
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
@@ -4336,6 +4353,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
pub async fn test_hash_squeeze_twice(
device: &wgpu::Device,
queue: &wgpu::Queue,
shader_src: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let test_vectors = generate_hash_squeeze_twice_test_vectors();
let total_tests = test_vectors.len();
@@ -4371,7 +4389,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
}}
}}
",
include_str!("mining.wgsl")
shader_src
);
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {

344
crates/metrics/src/lair.rs Normal file
View File

@@ -0,0 +1,344 @@
//! lair: metrics that attribute performance to builds, devices and jobs
//! (quantus/miner#9). Additive to origin's metrics in `lib.rs`; nothing there
//! changes, so the fleet dashboard keeps working across origin merges.
//!
//! Naming: `miner_*` like origin's. Per-device series carry `device` (engine
//! index) and `kernel` (the kernel id the engine selected), so a silent
//! fallback from one kernel to another shows up as a label change rather than
//! as an unexplained hashrate drop.
use crate::REGISTRY;
use once_cell::sync::Lazy;
use prometheus::{
Counter, Histogram, HistogramOpts, HistogramVec, IntCounter, IntCounterVec, IntGauge,
IntGaugeVec, Opts,
};
use std::time::Duration;
fn reg<M: prometheus::core::Collector + Clone + 'static>(m: M) -> M {
REGISTRY
.register(Box::new(m.clone()))
.expect("register lair metric");
m
}
// ---------------------------------------------------------------------------
// Identity: which build and which configuration produced the numbers
// ---------------------------------------------------------------------------
static BUILD_INFO: Lazy<IntGaugeVec> = Lazy::new(|| {
reg(IntGaugeVec::new(
Opts::new(
"miner_build_info",
"Build identity of the running miner, always 1",
),
&["version", "commit"],
)
.expect("miner_build_info"))
});
static CONFIG_INFO: Lazy<IntGaugeVec> = Lazy::new(|| {
reg(IntGaugeVec::new(
Opts::new(
"miner_config_info",
"Effective mining configuration, always 1",
),
&[
"engine",
"gpu_batch_size",
"gpu_devices",
"cpu_workers",
"gpu_throttle_ms",
],
)
.expect("miner_config_info"))
});
/// Set once at startup. `commit` is the SHA embedded by build.rs.
pub fn set_build_info(version: &str, commit: &str) {
BUILD_INFO.with_label_values(&[version, commit]).set(1);
}
/// Set once the engines are resolved. Two deploys of one commit with different
/// flags must not look like the same experiment.
pub fn set_config_info(
engine: &str,
gpu_batch_size: u32,
gpu_devices: usize,
cpu_workers: usize,
gpu_throttle_ms: u64,
) {
CONFIG_INFO
.with_label_values(&[
engine,
&gpu_batch_size.to_string(),
&gpu_devices.to_string(),
&cpu_workers.to_string(),
&gpu_throttle_ms.to_string(),
])
.set(1);
}
// ---------------------------------------------------------------------------
// Per device: throughput, outcomes, and where the time goes inside a batch
// ---------------------------------------------------------------------------
static DEVICE_HASHES: Lazy<IntCounterVec> = Lazy::new(|| {
reg(IntCounterVec::new(
Opts::new(
"miner_device_hashes_total",
"Hashes computed, per GPU device and kernel",
),
&["device", "kernel"],
)
.expect("miner_device_hashes_total"))
});
static DEVICE_SOLUTIONS: Lazy<IntCounterVec> = Lazy::new(|| {
reg(IntCounterVec::new(
Opts::new(
"miner_device_solutions_total",
"Solutions found, per GPU device and kernel",
),
&["device", "kernel"],
)
.expect("miner_device_solutions_total"))
});
static DEVICE_LOST: Lazy<IntCounterVec> = Lazy::new(|| {
reg(IntCounterVec::new(
Opts::new(
"miner_device_lost_total",
"Times a GPU device was lost or unresponsive and its worker stopped",
),
&["device", "kernel"],
)
.expect("miner_device_lost_total"))
});
static DEVICE_STALE_HASHES: Lazy<IntCounterVec> = Lazy::new(|| {
reg(IntCounterVec::new(
Opts::new(
"miner_stale_hashes_total",
"Hashes computed after the job they were for had been superseded: the batch in \
flight when a new job arrived. The wasted-work cost of batch size.",
),
&["device", "kernel"],
)
.expect("miner_stale_hashes_total"))
});
static GPU_BATCH_SECONDS: Lazy<HistogramVec> = Lazy::new(|| {
reg(HistogramVec::new(
HistogramOpts::new(
"miner_gpu_batch_seconds",
"Per-batch time split into the GPU executing (phase=gpu) and the host \
preparing, submitting and reading back (phase=host)",
)
.buckets(vec![
0.0005, 0.001, 0.002, 0.004, 0.008, 0.016, 0.032, 0.064, 0.128, 0.256, 0.512, 1.0, 2.0,
]),
&["device", "kernel", "phase"],
)
.expect("miner_gpu_batch_seconds"))
});
/// Handles for one device, resolved once so the per-batch path does no label
/// lookups. Created by the engine when it initialises a device.
#[derive(Clone)]
pub struct DeviceMetrics {
hashes: IntCounter,
solutions: IntCounter,
lost: IntCounter,
stale: IntCounter,
batch_gpu: Histogram,
batch_host: Histogram,
}
impl DeviceMetrics {
pub fn new(device: usize, kernel: &str) -> Self {
let d = device.to_string();
Self {
hashes: DEVICE_HASHES.with_label_values(&[&d, kernel]),
solutions: DEVICE_SOLUTIONS.with_label_values(&[&d, kernel]),
lost: DEVICE_LOST.with_label_values(&[&d, kernel]),
stale: DEVICE_STALE_HASHES.with_label_values(&[&d, kernel]),
batch_gpu: GPU_BATCH_SECONDS.with_label_values(&[&d, kernel, "gpu"]),
batch_host: GPU_BATCH_SECONDS.with_label_values(&[&d, kernel, "host"]),
}
}
pub fn record_hashes(&self, n: u64) {
self.hashes.inc_by(n);
}
pub fn record_solution(&self) {
self.solutions.inc();
}
pub fn record_device_lost(&self) {
self.lost.inc();
}
/// The batch that completed after its job was superseded.
pub fn record_stale_hashes(&self, n: u64) {
self.stale.inc_by(n);
}
/// `gpu` is submit-to-completion on the device; `host` is everything else
/// in the batch (buffer writes, encoding, readback, bookkeeping).
pub fn observe_batch(&self, gpu: Duration, host: Duration) {
self.batch_gpu.observe(gpu.as_secs_f64());
self.batch_host.observe(host.as_secs_f64());
}
}
// ---------------------------------------------------------------------------
// Jobs and results: the efficiency side of every throughput trade-off
// ---------------------------------------------------------------------------
static JOBS_RECEIVED: Lazy<IntCounter> = Lazy::new(|| {
reg(
IntCounter::new("miner_jobs_received_total", "Jobs received from the node")
.expect("miner_jobs_received_total"),
)
});
static JOB_PICKUP_SECONDS: Lazy<HistogramVec> = Lazy::new(|| {
reg(HistogramVec::new(
HistogramOpts::new(
"miner_job_pickup_seconds",
"Job issued to a worker starting on it. For a busy worker this is the \
time to notice cancellation and finish the in-flight batch",
)
.buckets(vec![
0.001, 0.002, 0.005, 0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0,
]),
&["engine"],
)
.expect("miner_job_pickup_seconds"))
});
static RESULTS_SUBMITTED: Lazy<IntCounter> = Lazy::new(|| {
reg(IntCounter::new(
"miner_results_submitted_total",
"Solutions sent to the node",
)
.expect("miner_results_submitted_total"))
});
static RESULTS_SEND_FAILED: Lazy<IntCounter> = Lazy::new(|| {
reg(IntCounter::new(
"miner_results_send_failed_total",
"Solutions that could not be sent to the node",
)
.expect("miner_results_send_failed_total"))
});
static SEAL_LATENCY: Lazy<Histogram> = Lazy::new(|| {
reg(Histogram::with_opts(
HistogramOpts::new(
"miner_seal_latency_seconds",
"Solution found on a worker to the result sent to the node",
)
.buckets(vec![
0.001, 0.002, 0.005, 0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0,
]),
)
.expect("miner_seal_latency_seconds"))
});
static JOB_IDLE_SECONDS: Lazy<Counter> = Lazy::new(|| {
reg(Counter::new(
"miner_job_idle_seconds_total",
"Seconds spent between sending a result and receiving the next job (node-attributable idle)",
)
.expect("miner_job_idle_seconds_total"))
});
pub fn record_job_received() {
JOBS_RECEIVED.inc();
}
pub fn observe_job_pickup(engine: &str, d: Duration) {
JOB_PICKUP_SECONDS
.with_label_values(&[engine])
.observe(d.as_secs_f64());
}
pub fn record_result_submitted(found_to_sent: Option<Duration>) {
RESULTS_SUBMITTED.inc();
if let Some(d) = found_to_sent {
SEAL_LATENCY.observe(d.as_secs_f64());
}
}
pub fn record_result_send_failed() {
RESULTS_SEND_FAILED.inc();
}
pub fn record_job_idle(d: Duration) {
JOB_IDLE_SECONDS.inc_by(d.as_secs_f64());
}
// ---------------------------------------------------------------------------
// Connection to the node: is a hashrate drop the miner or the node?
// ---------------------------------------------------------------------------
static CONNECTS: Lazy<IntCounter> = Lazy::new(|| {
reg(IntCounter::new(
"miner_connects_total",
"Connections established to the node",
)
.expect("miner_connects_total"))
});
static CONNECT_FAILURES: Lazy<IntCounter> = Lazy::new(|| {
reg(IntCounter::new(
"miner_connect_failures_total",
"Connection attempts to the node that failed",
)
.expect("miner_connect_failures_total"))
});
static DISCONNECTS: Lazy<IntCounter> = Lazy::new(|| {
reg(IntCounter::new(
"miner_disconnects_total",
"Connections to the node that were lost",
)
.expect("miner_disconnects_total"))
});
static CONNECTED: Lazy<IntGauge> = Lazy::new(|| {
reg(
IntGauge::new("miner_connected", "1 while connected to the node, else 0")
.expect("miner_connected"),
)
});
static DISCONNECTED_SECONDS: Lazy<Counter> = Lazy::new(|| {
reg(Counter::new(
"miner_disconnected_seconds_total",
"Seconds spent without a node connection after the first successful connection",
)
.expect("miner_disconnected_seconds_total"))
});
pub fn record_connected() {
CONNECTS.inc();
CONNECTED.set(1);
}
pub fn record_connect_failed() {
CONNECT_FAILURES.inc();
CONNECTED.set(0);
}
pub fn record_disconnected() {
DISCONNECTS.inc();
CONNECTED.set(0);
}
pub fn record_disconnected_time(d: Duration) {
DISCONNECTED_SECONDS.inc_by(d.as_secs_f64());
}

View File

@@ -39,6 +39,10 @@ use anyhow::Result;
static REGISTRY: Lazy<Registry> = Lazy::new(Registry::new);
// lair: build, device and job metrics (quantus/miner#9); see lair.rs.
mod lair;
pub use lair::*;
// ---------------------------------------------------------------------------
// Hash Rate Metrics
// ---------------------------------------------------------------------------

View File

@@ -8,6 +8,7 @@ description = "CLI binary to run the Quantus External Miner service"
[dependencies]
miner-service = { path = "../miner-service" }
metrics = { path = "../metrics", features = ["http-exporter"] }
quic-transport = { path = "../quic-transport" }
clap = { workspace = true, features = ["derive", "env"] }
tokio = { workspace = true, features = ["full"] }
env_logger = { workspace = true }

51
crates/miner-cli/build.rs Normal file
View File

@@ -0,0 +1,51 @@
// lair: embed the git commit in the binary so `--version` and the build-info
// metric identify a deployed build by commit, not by the workspace semver
// (which does not change between commits on a branch that deploys on push).
//
// Resolution order:
// 1. MINER_BUILD_SHA in the environment (CI sets it from the checked-out ref)
// 2. `git rev-parse HEAD` of the workspace, with "-dirty" if the tree differs
// 3. "unknown"
use std::process::Command;
fn git(args: &[&str]) -> Option<String> {
let out = Command::new("git").args(args).output().ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8(out.stdout).ok()?;
let s = s.trim();
if s.is_empty() {
None
} else {
Some(s.to_string())
}
}
fn main() {
println!("cargo:rerun-if-env-changed=MINER_BUILD_SHA");
let sha = match std::env::var("MINER_BUILD_SHA") {
Ok(s) if !s.trim().is_empty() => s.trim().to_string(),
_ => match git(&["rev-parse", "--short=12", "HEAD"]) {
Some(head) => {
// Re-run when HEAD moves so a rebuild after a commit picks it up.
if let Some(dir) = git(&["rev-parse", "--git-dir"]) {
println!("cargo:rerun-if-changed={dir}/HEAD");
println!("cargo:rerun-if-changed={dir}/refs/heads");
}
let dirty = git(&["status", "--porcelain", "--untracked-files=no"])
.map(|s| !s.is_empty())
.unwrap_or(false);
if dirty {
format!("{head}-dirty")
} else {
head
}
}
None => "unknown".to_string(),
},
};
println!("cargo:rustc-env=MINER_BUILD_SHA={sha}");
}

View File

@@ -3,6 +3,7 @@ use engine_cpu::{AtomicBoolCancelCheck, EngineRange, MinerEngine};
use miner_service::{run, ServiceConfig};
use primitive_types::U512;
use rand::RngCore;
use std::path::PathBuf;
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use std::thread;
@@ -20,6 +21,34 @@ enum Command {
#[arg(long, env = "MINER_NODE_ADDR", default_value = "127.0.0.1:9833")]
node_addr: std::net::SocketAddr,
/// Shared auth token from the node's `miner-auth-token` file
/// (`<base-path>/chains/<chain>/miner-auth-token`). Prefer `--auth-token-file`.
#[arg(long, env = "MINER_AUTH_TOKEN", conflicts_with = "auth_token_file")]
auth_token: Option<String>,
/// Path to the node's `miner-auth-token` file (trimmed). Preferred over
/// `--auth-token` so the secret is not placed on the command line.
#[arg(long, env = "MINER_AUTH_TOKEN_FILE", conflicts_with = "auth_token")]
auth_token_file: Option<PathBuf>,
/// SHA-256 fingerprint of the node's miner TLS certificate (64 hex chars).
/// Prefer `--tls-cert-sha256-file` pointing at `miner-tls-cert-sha256`
/// (the node also logs this fingerprint).
#[arg(
long,
env = "MINER_TLS_CERT_SHA256",
conflicts_with = "tls_cert_sha256_file"
)]
tls_cert_sha256: Option<String>,
/// Path to the node's `miner-tls-cert-sha256` file.
#[arg(
long,
env = "MINER_TLS_CERT_SHA256_FILE",
conflicts_with = "tls_cert_sha256"
)]
tls_cert_sha256_file: Option<PathBuf>,
/// Number of CPU worker threads to use for mining (default: auto-detect)
#[arg(long = "cpu-workers", env = "MINER_CPU_WORKERS")]
cpu_workers: Option<usize>,
@@ -29,11 +58,11 @@ enum Command {
gpu_devices: Option<usize>,
/// GPU batch size in nonces - controls how often GPU checks for cancellation
#[arg(long = "gpu-batch-size", env = "MINER_GPU_BATCH_SIZE", default_value_t = DEFAULT_GPU_BATCH_SIZE)]
#[arg(long = "gpu-batch-size", env = "MINER_GPU_BATCH_SIZE", default_value_t = DEFAULT_GPU_BATCH_SIZE, value_parser = clap::value_parser!(u32).range(1..))]
gpu_batch_size: u32,
/// CPU batch size in hashes - controls how often CPU checks for cancellation
#[arg(long = "cpu-batch-size", env = "MINER_CPU_BATCH_SIZE", default_value_t = DEFAULT_CPU_BATCH_SIZE)]
#[arg(long = "cpu-batch-size", env = "MINER_CPU_BATCH_SIZE", default_value_t = DEFAULT_CPU_BATCH_SIZE, value_parser = clap::value_parser!(u64).range(1..))]
cpu_batch_size: u64,
/// Port for Prometheus metrics HTTP endpoint (default: 9900)
@@ -58,6 +87,11 @@ enum Command {
#[arg(long = "allow-integrated", env = "MINER_ALLOW_INTEGRATED")]
allow_integrated: bool,
/// lair: GPU engine: auto (CUDA when this binary carries a kernel and a
/// driver is present, else wgpu), cuda, or wgpu (quantus/miner#3).
#[arg(long = "gpu-engine", env = "MINER_GPU_ENGINE", default_value = "auto")]
gpu_engine: String,
/// Enable verbose logging
#[arg(short, long, env = "MINER_VERBOSE")]
verbose: bool,
@@ -74,11 +108,11 @@ enum Command {
gpu_devices: Option<usize>,
/// GPU batch size in nonces - controls how often GPU checks for cancellation
#[arg(long = "gpu-batch-size", env = "MINER_GPU_BATCH_SIZE", default_value_t = DEFAULT_GPU_BATCH_SIZE)]
#[arg(long = "gpu-batch-size", env = "MINER_GPU_BATCH_SIZE", default_value_t = DEFAULT_GPU_BATCH_SIZE, value_parser = clap::value_parser!(u32).range(1..))]
gpu_batch_size: u32,
/// CPU batch size in hashes - controls how often CPU checks for cancellation
#[arg(long = "cpu-batch-size", env = "MINER_CPU_BATCH_SIZE", default_value_t = DEFAULT_CPU_BATCH_SIZE)]
#[arg(long = "cpu-batch-size", env = "MINER_CPU_BATCH_SIZE", default_value_t = DEFAULT_CPU_BATCH_SIZE, value_parser = clap::value_parser!(u64).range(1..))]
cpu_batch_size: u64,
/// Benchmark duration in seconds (default: 10)
@@ -89,15 +123,29 @@ enum Command {
#[arg(long = "allow-integrated", env = "MINER_ALLOW_INTEGRATED")]
allow_integrated: bool,
/// lair: GPU engine: auto (CUDA when this binary carries a kernel and a
/// driver is present, else wgpu), cuda, or wgpu (quantus/miner#3).
#[arg(long = "gpu-engine", env = "MINER_GPU_ENGINE", default_value = "auto")]
gpu_engine: String,
/// Enable verbose logging
#[arg(short, long, env = "MINER_VERBOSE")]
verbose: bool,
},
}
// lair: semver plus the commit the binary was built from (see build.rs), so a
// deploy can assert the running binary is the commit it shipped.
const VERSION: &str = concat!(
env!("CARGO_PKG_VERSION"),
" (",
env!("MINER_BUILD_SHA"),
")"
);
/// Quantus External Miner CLI
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
#[command(author, version = VERSION, about, long_about = None)]
struct Args {
#[command(subcommand)]
command: Option<Command>,
@@ -106,16 +154,26 @@ struct Args {
#[tokio::main]
async fn main() {
let args = Args::parse();
// lair: identify this build in the metrics (quantus/miner#9).
metrics::set_build_info(env!("CARGO_PKG_VERSION"), env!("MINER_BUILD_SHA"));
let Some(command) = args.command else {
eprintln!("Error: No command provided. Use 'serve' to start mining (defaults to local node at 127.0.0.1:9833).");
eprintln!("Example: quantus-miner serve --node-addr 127.0.0.1:9833");
eprintln!(
"Example: quantus-miner serve --node-addr 127.0.0.1:9833 \
--auth-token-file /path/to/miner-auth-token \
--tls-cert-sha256-file /path/to/miner-tls-cert-sha256"
);
std::process::exit(1);
};
match command {
Command::Serve {
node_addr,
auth_token,
auth_token_file,
tls_cert_sha256,
tls_cert_sha256_file,
cpu_workers,
gpu_devices,
gpu_batch_size,
@@ -123,10 +181,32 @@ async fn main() {
gpu_throttle_ms,
metrics_port,
allow_integrated,
gpu_engine,
verbose,
} => {
init_logger(verbose);
let auth_token = match resolve_auth_token(auth_token, auth_token_file) {
Ok(token) => token,
Err(e) => {
eprintln!("Error: {e}");
std::process::exit(1);
}
};
let tls_cert_sha256 =
match resolve_tls_cert_sha256(tls_cert_sha256, tls_cert_sha256_file) {
Ok(fp) => fp,
Err(e) => {
eprintln!("Error: {e}");
std::process::exit(1);
}
};
// Fail closed on permanent misconfig before metrics/workers start.
if let Err(e) = quic_transport::validate_auth_config(&auth_token, &tls_cert_sha256) {
eprintln!("Error: {e}");
std::process::exit(1);
}
log::info!("Starting external miner service...");
// Start metrics HTTP server
@@ -141,12 +221,15 @@ async fn main() {
let config = ServiceConfig {
node_addr,
auth_token,
tls_cert_sha256,
cpu_workers,
gpu_devices,
gpu_batch_size,
cpu_batch_size,
gpu_throttle_ms,
allow_integrated,
gpu_engine,
};
if let Err(e) = run(config).await {
@@ -162,6 +245,7 @@ async fn main() {
cpu_batch_size,
duration,
allow_integrated,
gpu_engine,
verbose,
} => {
init_logger(verbose);
@@ -172,19 +256,71 @@ async fn main() {
cpu_batch_size,
duration,
allow_integrated,
gpu_engine,
)
.await;
}
}
}
fn resolve_auth_token(
auth_token: Option<String>,
auth_token_file: Option<PathBuf>,
) -> Result<String, String> {
resolve_required_secret(
auth_token,
auth_token_file,
"--auth-token",
"--auth-token-file",
"miner auth token required: pass --auth-token-file <PATH> to the node's \
miner-auth-token file (or --auth-token <TOKEN>)",
)
}
fn resolve_tls_cert_sha256(value: Option<String>, file: Option<PathBuf>) -> Result<String, String> {
resolve_required_secret(
value,
file,
"--tls-cert-sha256",
"--tls-cert-sha256-file",
"TLS cert fingerprint required: pass --tls-cert-sha256-file <PATH> to the node's \
miner-tls-cert-sha256 file (or --tls-cert-sha256 <HEX>; also printed in node logs)",
)
}
fn resolve_required_secret(
value: Option<String>,
file: Option<PathBuf>,
value_flag: &str,
file_flag: &str,
missing_msg: &str,
) -> Result<String, String> {
if let Some(value) = value {
let value = value.trim().to_string();
if value.is_empty() {
return Err(format!("{value_flag} is empty"));
}
return Ok(value);
}
if let Some(path) = file {
let contents = std::fs::read_to_string(&path)
.map_err(|e| format!("failed to read {file_flag} {}: {}", path.display(), e))?;
let value = contents.trim().to_string();
if value.is_empty() {
return Err(format!("{file_flag} {} is empty", path.display()));
}
return Ok(value);
}
Err(missing_msg.into())
}
fn init_logger(verbose: bool) {
if std::env::var("RUST_LOG").is_err() {
// Filter out noisy wgpu/naga shader compilation logs
let log_level = if verbose {
"debug,miner=debug,gpu_engine=debug,engine_cpu=debug,wgpu=warn,wgpu_core=warn,wgpu_hal=warn,naga=warn"
"debug,miner=debug,gpu_engine=debug,cuda_engine=debug,engine_cpu=debug,wgpu=warn,wgpu_core=warn,wgpu_hal=warn,naga=warn"
} else {
"info,miner=info,gpu_engine=info,wgpu=error,wgpu_core=error,wgpu_hal=error,naga=error"
"info,miner=info,gpu_engine=info,cuda_engine=info,wgpu=error,wgpu_core=error,wgpu_hal=error,naga=error"
};
std::env::set_var("RUST_LOG", log_level);
}
@@ -198,6 +334,7 @@ async fn run_benchmark(
cpu_batch_size: u64,
duration: u64,
allow_integrated: bool,
gpu_engine_pref: String,
) {
let effective_cpu_workers = cpu_workers.unwrap_or_else(num_cpus::get);
@@ -207,6 +344,7 @@ async fn run_benchmark(
gpu_batch_size,
0,
allow_integrated,
&gpu_engine_pref,
) {
Ok((engine, count)) => (engine, count),
Err(e) => {
@@ -225,7 +363,13 @@ async fn run_benchmark(
num_cpus::get()
);
println!("GPU Devices: {}", effective_gpu_devices);
println!("Duration: {} seconds", duration);
if effective_cpu_workers > 0 {
println!("CPU batch size: {cpu_batch_size} hashes");
}
if effective_gpu_devices > 0 {
println!("GPU batch size: {gpu_batch_size} nonces");
}
println!("Duration: {duration} seconds");
println!();
if total_workers == 0 {
@@ -257,8 +401,11 @@ async fn run_benchmark(
let mut handles = Vec::new();
let total_hashes = Arc::new(std::sync::Mutex::new(0u64));
let cpu_chunk = 10_000u64;
let gpu_chunk = 1_000_000u64;
// Floor range widths at the old constants: engines still batch at the flag's
// size internally, but tiny flags don't turn per-call harness overhead into
// the measured quantity.
let cpu_chunk = cpu_batch_size.max(10_000);
let gpu_chunk = (gpu_batch_size as u64).max(1_000_000);
for worker_id in 0..total_workers {
let (engine, nonces_per_batch) = if worker_id < effective_cpu_workers {
@@ -274,21 +421,23 @@ async fn run_benchmark(
let handle = thread::spawn(move || {
let stride = U512::from(1_000_000_000_000u64);
let worker_start = U512::from(worker_id as u64).saturating_mul(stride);
let worker_range = EngineRange {
start: worker_start,
end: worker_start
.saturating_add(U512::from(nonces_per_batch))
.saturating_sub(U512::from(1u64)),
};
let mut current = U512::from(worker_id as u64).saturating_mul(stride);
let step = U512::from(nonces_per_batch);
loop {
if cancel.load(std::sync::atomic::Ordering::Relaxed) {
break;
}
let worker_range = EngineRange {
start: current,
end: current
.saturating_add(step)
.saturating_sub(U512::from(1u64)),
};
let cancel_check = AtomicBoolCancelCheck(&cancel);
let result = engine.search_range(&ctx, worker_range.clone(), &cancel_check);
let result = engine.search_range(&ctx, worker_range, &cancel_check);
match result {
engine_cpu::EngineStatus::Found { hash_count, .. }
@@ -300,11 +449,12 @@ async fn run_benchmark(
engine_cpu::EngineStatus::Running { .. } => {}
}
// Exit if device is lost
if matches!(result, engine_cpu::EngineStatus::DeviceLost { .. }) {
break;
}
current = current.saturating_add(step);
if start.elapsed() >= Duration::from_secs(duration) {
break;
}

View File

@@ -21,7 +21,7 @@ anyhow = { workspace = true }
# QUIC transport
quinn = "0.10"
rustls = { version = "0.21", default-features = false, features = ["dangerous_configuration", "quic"] }
quic-transport = { path = "../quic-transport" }
getrandom = "0.2"
# Protocol types from the node repo
@@ -31,4 +31,5 @@ quantus-miner-api = { workspace = true }
pow-core = { path = "../pow-core" }
engine-cpu = { path = "../engine-cpu", optional = true }
engine-gpu = { path = "../engine-gpu" }
engine-cuda = { path = "../engine-cuda" } # lair: quantus/miner#3
metrics = { path = "../metrics" }

View File

@@ -23,6 +23,10 @@ use std::thread;
pub struct ServiceConfig {
/// Address of the node to connect to (e.g., "127.0.0.1:9833").
pub node_addr: std::net::SocketAddr,
/// Shared secret that must match the node's miner auth token.
pub auth_token: String,
/// SHA-256 fingerprint (hex) of the node's miner TLS certificate DER.
pub tls_cert_sha256: String,
/// Number of CPU worker threads to use for mining (None = auto-detect)
pub cpu_workers: Option<usize>,
/// Number of GPU devices to use for mining (None = auto-detect)
@@ -35,6 +39,9 @@ pub struct ServiceConfig {
pub gpu_throttle_ms: u64,
/// Allow integrated GPUs even when discrete GPUs are available
pub allow_integrated: bool,
/// lair: GPU engine preference: "auto" (CUDA when available, else wgpu),
/// "cuda" or "wgpu" (quantus/miner#3).
pub gpu_engine: String,
}
/// Engine type for tracking metrics per compute type.
@@ -58,6 +65,8 @@ pub struct WorkerResult {
pub hash_count: u64,
/// Whether this worker has finished its range.
pub completed: bool,
/// lair: when the candidate was found (seal latency, quantus/miner#9).
pub found_at: Option<std::time::Instant>,
}
/// A successful mining candidate.
@@ -86,6 +95,8 @@ pub struct MiningJob {
pub ctx: pow_core::JobContext,
/// Job ID to detect stale results after job transitions
pub job_id: u64,
/// lair: when the job was issued (pickup latency, quantus/miner#9).
created_at: std::time::Instant,
}
/// Persistent worker thread pool that keeps threads alive between jobs.
@@ -204,6 +215,7 @@ impl WorkerPool {
let job = MiningJob {
ctx,
job_id: new_job_id,
created_at: std::time::Instant::now(), // lair
};
// Dispatch job to all workers using bounded channels (capacity 16).
@@ -336,6 +348,9 @@ fn worker_loop(
log::debug!("[WORKER {type_str}-{thread_id}] Drained {skipped} stale jobs from queue");
}
// lair: issued-to-picked-up; for a busy worker this is the cancel latency.
metrics::observe_job_pickup(type_str, job.created_at.elapsed());
// Capture the job's ID for later validation
let job_id = job.job_id;
log::debug!("[WORKER {type_str}-{thread_id}] Received job {job_id}");
@@ -394,6 +409,7 @@ fn worker_loop(
candidate: None, // Discard the stale candidate
hash_count,
completed: true,
found_at: None,
});
continue;
}
@@ -438,6 +454,7 @@ fn worker_loop(
candidate: None,
hash_count,
completed: true,
found_at: None,
});
break; // Exit the worker loop
}
@@ -448,6 +465,7 @@ fn worker_loop(
};
// Send result (non-blocking to avoid deadlock if receiver is full)
let found_at = candidate.as_ref().map(|_| std::time::Instant::now()); // lair
let _ = result_tx.try_send(WorkerResult {
thread_id,
engine_type,
@@ -455,12 +473,14 @@ fn worker_loop(
candidate,
hash_count,
completed: true,
found_at,
});
}
// Clean up GPU resources on thread exit
if engine_type == EngineType::Gpu {
engine_gpu::GpuEngine::clear_worker_resources();
engine_cuda::CudaEngine::clear_worker_resources(); // lair
}
log::debug!("{type_str} worker {thread_id} exited");
@@ -472,12 +492,37 @@ pub fn resolve_gpu_configuration(
batch_size: u32,
throttle_ms: u64,
allow_integrated: bool,
gpu_engine: &str,
) -> anyhow::Result<(Option<Arc<dyn MinerEngine>>, usize)> {
// Explicit 0 means no GPU
if requested_devices == Some(0) {
return Ok((None, 0));
}
// lair: native CUDA engine first unless wgpu was asked for (quantus/miner#3).
if gpu_engine != "wgpu" {
match engine_cuda::CudaEngine::try_new(batch_size, throttle_ms) {
Ok(engine) => {
let available = engine.device_count();
let count = match requested_devices {
Some(n) if n > available => anyhow::bail!(
"Requested {n} GPU devices but only {available} available (CUDA)"
),
Some(n) => n,
None => {
log::info!("Auto-detected {available} CUDA device(s)");
available
}
};
return Ok((Some(Arc::new(engine)), count));
}
Err(e) if gpu_engine == "cuda" => {
anyhow::bail!("CUDA engine requested but unavailable: {e}")
}
Err(e) => log::info!("CUDA engine unavailable ({e}); using wgpu"),
}
}
// Try to initialize GPU engine
let engine = engine_gpu::GpuEngine::try_new(batch_size, throttle_ms, allow_integrated);
let engine = match engine {
@@ -525,6 +570,7 @@ pub async fn run(config: ServiceConfig) -> anyhow::Result<()> {
config.gpu_batch_size,
config.gpu_throttle_ms,
config.allow_integrated,
&config.gpu_engine,
)?;
// Resolve CPU workers
@@ -558,6 +604,14 @@ pub async fn run(config: ServiceConfig) -> anyhow::Result<()> {
cpu_workers,
gpu_devices
);
// lair: expose the effective configuration as labels (quantus/miner#9).
metrics::set_config_info(
gpu_engine.as_ref().map(|e| e.name()).unwrap_or("cpu"),
config.gpu_batch_size,
gpu_devices,
cpu_workers,
config.gpu_throttle_ms,
);
if let Some(ref engine) = cpu_engine {
let name = engine.name();
@@ -582,6 +636,8 @@ pub async fn run(config: ServiceConfig) -> anyhow::Result<()> {
log::info!("🌐 Connecting to node at {node_addr}");
quic::connect_and_mine(
config.node_addr,
&config.auth_token,
&config.tls_cert_sha256,
cpu_engine,
gpu_engine,
cpu_workers,

View File

@@ -10,8 +10,6 @@ use std::time::{Duration, Instant};
use engine_cpu::MinerEngine;
use primitive_types::U512;
use quinn::{ClientConfig, Endpoint};
use rustls::client::ServerCertVerified;
use quantus_miner_api::{
read_message, write_message, ApiResponseStatus, MinerMessage, MiningResult,
@@ -28,6 +26,8 @@ use pow_core::format_hashrate;
/// Uses a persistent worker pool to avoid thread creation overhead between jobs.
pub async fn connect_and_mine(
node_addr: SocketAddr,
auth_token: &str,
tls_cert_sha256: &str,
cpu_engine: Option<Arc<dyn MinerEngine>>,
gpu_engine: Option<Arc<dyn MinerEngine>>,
cpu_workers: usize,
@@ -38,23 +38,53 @@ pub async fn connect_and_mine(
let mut reconnect_delay = Duration::from_secs(1);
const MAX_RECONNECT_DELAY: Duration = Duration::from_secs(30);
// lair: time without a connection, counted after the first success.
let mut disconnected_since: Option<Instant> = None;
loop {
log::info!("⛏️ Connecting to node at {}...", node_addr);
match establish_connection(node_addr).await {
match establish_connection(node_addr, auth_token, tls_cert_sha256).await {
Ok((connection, send, recv)) => {
log::info!("⛏️ Connected to node at {}", node_addr);
reconnect_delay = Duration::from_secs(1);
metrics::record_connected(); // lair
if let Some(t) = disconnected_since.take() {
metrics::record_disconnected_time(t.elapsed());
}
if let Err(e) = handle_connection(connection, send, recv, &worker_pool).await {
log::info!("⛏️ Connection lost: {}", e);
let mut authenticated = false;
if let Err(e) =
handle_connection(connection, send, recv, &worker_pool, &mut authenticated)
.await
{
// Cancel any running job when connection drops
worker_pool.cancel();
if e.downcast_ref::<quic_transport::PermanentConnectError>()
.is_some()
{
log::error!("⛏️ Permanent connection error (not retrying): {e}");
return Err(e);
}
log::info!("⛏️ Connection lost: {}", e);
}
metrics::record_disconnected(); // lair
disconnected_since = Some(Instant::now());
// Only clear backoff after the node accepted auth (first NewJob).
// connect() already treats explicit "auth failed" as permanent;
// this covers any other post-Ready close that looked like success.
if authenticated {
reconnect_delay = Duration::from_secs(1);
}
}
Err(e) => {
if e.downcast_ref::<quic_transport::PermanentConnectError>()
.is_some()
{
log::error!("⛏️ Permanent connection error (not retrying): {e}");
return Err(e);
}
log::warn!("⛏️ Failed to connect to node: {}", e);
metrics::record_connect_failed(); // lair
}
}
@@ -64,38 +94,18 @@ pub async fn connect_and_mine(
}
}
/// Establish a QUIC connection to the node.
/// Establish a QUIC connection to the node (shared transport crate).
async fn establish_connection(
addr: SocketAddr,
auth_token: &str,
tls_cert_sha256: &str,
) -> anyhow::Result<(quinn::Connection, quinn::SendStream, quinn::RecvStream)> {
let mut crypto = rustls::ClientConfig::builder()
.with_safe_defaults()
.with_custom_certificate_verifier(Arc::new(InsecureCertVerifier))
.with_no_client_auth();
crypto.alpn_protocols = vec![b"quantus-miner".to_vec()];
let mut client_config = ClientConfig::new(Arc::new(crypto));
let mut transport_config = quinn::TransportConfig::default();
transport_config.keep_alive_interval(Some(Duration::from_secs(5)));
transport_config.max_idle_timeout(Some(Duration::from_secs(15).try_into().unwrap()));
client_config.transport_config(Arc::new(transport_config));
let mut endpoint = Endpoint::client("0.0.0.0:0".parse().unwrap())?;
endpoint.set_default_client_config(client_config);
let connection = endpoint.connect(addr, "localhost")?.await?;
log::info!("⛏️ QUIC connection established to {}", addr);
log::info!("⛏️ Opening bidirectional stream to node...");
let (mut send, recv) = connection.open_bi().await?;
// Send Ready message to establish the stream
write_message(&mut send, &MinerMessage::Ready).await?;
log::info!("⛏️ Bidirectional stream established");
Ok((connection, send, recv))
let result = quic_transport::connect(addr, auth_token, tls_cert_sha256).await?;
log::info!(
"⛏️ QUIC connection and bidirectional stream established to {}",
addr
);
Ok(result)
}
/// Helper to send a message while monitoring connection health.
@@ -116,11 +126,15 @@ async fn send_message_checked(
}
/// Handle an established connection, receiving jobs and sending results.
///
/// Sets `authenticated` when the first `NewJob` arrives (proof the node
/// accepted our Ready token).
async fn handle_connection(
connection: quinn::Connection,
mut send: quinn::SendStream,
mut recv: quinn::RecvStream,
worker_pool: &WorkerPool,
authenticated: &mut bool,
) -> anyhow::Result<()> {
use crossbeam_channel::RecvTimeoutError;
@@ -140,6 +154,8 @@ async fn handle_connection(
let mut cpu_hashes: u64 = 0;
let mut gpu_hashes: u64 = 0;
let mut result_sent_for_current_job = false;
// lair: when the last result went out, for node-attributable idle time.
let mut result_sent_at: Option<Instant> = None;
log::info!("⛏️ Waiting for mining jobs from node...");
@@ -209,7 +225,15 @@ async fn handle_connection(
};
let msg = MinerMessage::JobResult(result);
send_message_checked(&connection, &mut send, &msg).await?;
if let Err(e) = send_message_checked(&connection, &mut send, &msg).await {
metrics::record_result_send_failed(); // lair
return Err(e);
}
// lair: seal latency and the start of the idle window.
metrics::record_result_submitted(
worker_result.found_at.map(|t| t.elapsed()),
);
result_sent_at = Some(Instant::now());
}
}
}
@@ -220,12 +244,24 @@ async fn handle_connection(
biased;
reason = connection.closed() => {
return Err(anyhow::anyhow!("Connection closed: {}", reason));
let msg = reason.to_string();
if !*authenticated && msg.to_ascii_lowercase().contains("auth") {
return Err(quic_transport::PermanentConnectError(format!(
"node rejected miner auth ({msg}); check --auth-token / miner-auth-token"
))
.into());
}
return Err(anyhow::anyhow!("Connection closed: {}", msg));
}
msg_result = read_message(&mut recv) => {
match msg_result {
Ok(MinerMessage::NewJob(request)) => {
*authenticated = true;
metrics::record_job_received(); // lair
if let Some(t) = result_sent_at.take() {
metrics::record_job_idle(t.elapsed());
}
log::info!(
"⛏️ Received job: id={}, hash=0x{}",
request.job_id,
@@ -305,7 +341,7 @@ async fn handle_connection(
Ok(MinerMessage::JobResult(_)) => {
log::warn!("Received unexpected JobResult from node");
}
Ok(MinerMessage::Ready) => {
Ok(MinerMessage::Ready { .. }) => {
log::warn!("Received unexpected Ready from node");
}
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
@@ -322,20 +358,3 @@ async fn handle_connection(
}
}
}
/// Certificate verifier that accepts any certificate (for self-signed certs).
struct InsecureCertVerifier;
impl rustls::client::ServerCertVerifier for InsecureCertVerifier {
fn verify_server_cert(
&self,
_end_entity: &rustls::Certificate,
_intermediates: &[rustls::Certificate],
_server_name: &rustls::ServerName,
_scts: &mut dyn Iterator<Item = &[u8]>,
_ocsp_response: &[u8],
_now: std::time::SystemTime,
) -> Result<ServerCertVerified, rustls::Error> {
Ok(ServerCertVerified::assertion())
}
}

View File

@@ -0,0 +1,32 @@
[package]
name = "pool-service"
version.workspace = true
edition.workspace = true
publish = false
description = "Captcha share pool: turns browser proof-of-work captcha solves into Quantus mining shares"
[dependencies]
anyhow = { workspace = true }
clap = { workspace = true }
env_logger = { workspace = true }
hex = { workspace = true }
log = { workspace = true }
primitive-types = { workspace = true }
rand = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true, features = ["std"] }
tokio = { workspace = true }
warp = { workspace = true }
# QUIC transport (shared with miner-service)
quinn = "0.10"
quic-transport = { path = "../quic-transport" }
# Constant-time comparison for the site secret
constant_time_eq = "0.3"
# Protocol types from the node repo
quantus-miner-api = { workspace = true }
# Local crates
pow-core = { path = "../pow-core" }

View File

@@ -0,0 +1,219 @@
//! HTTP API for the captcha widget (session/share) and for protected sites
//! (siteverify), plus optional static file serving for the demo page.
use std::net::{IpAddr, SocketAddr};
use std::path::PathBuf;
use std::sync::Arc;
use primitive_types::U512;
use serde::{Deserialize, Serialize};
use warp::Filter;
use crate::rate_limit::SessionIssuerLimiter;
use crate::state::{PoolState, ShareOutcome};
#[derive(Serialize)]
struct SessionResponse {
session_id: String,
/// Hex, 32 bytes, no 0x prefix.
header_hash: String,
/// Hex U512, no 0x prefix. Solver iterates nonce_start..nonce_end.
nonce_start: String,
nonce_end: String,
/// Share target as hex U512: a share is valid iff hash < share_target.
/// (Target form avoids the solver needing U512 division.)
share_target: String,
/// Expected number of hashes to find a share (= share difficulty), FYI/UX.
expected_hashes: u64,
expires_in_secs: u64,
}
#[derive(Deserialize)]
struct ShareRequest {
session_id: String,
/// Hex U512 nonce, no 0x prefix.
nonce: String,
}
#[derive(Serialize)]
struct ShareResponse {
success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
token: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<&'static str>,
block_found: bool,
}
/// Request shape mirrors reCAPTCHA/Turnstile: `secret` + `response`.
#[derive(Deserialize)]
struct SiteVerifyRequest {
secret: String,
response: String,
}
#[derive(Serialize)]
struct SiteVerifyResponse {
success: bool,
#[serde(rename = "challenge_ts", skip_serializing_if = "Option::is_none")]
challenge_ts: Option<u64>,
#[serde(rename = "error-codes", skip_serializing_if = "Vec::is_empty")]
error_codes: Vec<&'static str>,
}
pub async fn serve(
state: Arc<PoolState>,
limiter: Arc<SessionIssuerLimiter>,
addr: SocketAddr,
serve_dir: Option<PathBuf>,
) {
let with_state = {
let state = state.clone();
warp::any().map(move || state.clone())
};
let with_limiter = {
let limiter = limiter.clone();
warp::any().map(move || limiter.clone())
};
// POST /api/session -> new captcha challenge
let session = warp::path!("api" / "session")
.and(warp::post())
.and(warp::addr::remote())
.and(with_limiter.clone())
.and(with_state.clone())
.map(
|remote: Option<SocketAddr>,
limiter: Arc<SessionIssuerLimiter>,
state: Arc<PoolState>| {
let ip = remote
.map(|a| a.ip())
.unwrap_or(IpAddr::from([127, 0, 0, 1]));
if let Err(reason) = limiter.check(ip) {
return reply_error(warp::http::StatusCode::TOO_MANY_REQUESTS, reason);
}
match state.issue_session() {
Err(reason) => reply_error(warp::http::StatusCode::SERVICE_UNAVAILABLE, reason),
Ok(s) => {
let target = U512::MAX / s.share_difficulty;
let expected = s.share_difficulty.min(U512::from(u64::MAX)).as_u64();
warp::reply::with_status(
warp::reply::json(&SessionResponse {
session_id: s.id,
header_hash: hex::encode(s.job.header),
nonce_start: format!("{:x}", s.nonce_start),
nonce_end: format!("{:x}", s.nonce_end),
share_target: format!("{:x}", target),
expected_hashes: expected,
expires_in_secs: crate::state::SESSION_TTL.as_secs(),
}),
warp::http::StatusCode::OK,
)
}
}
},
);
// POST /api/share -> verify a solved nonce, mint a token
let share = warp::path!("api" / "share")
.and(warp::post())
.and(warp::body::content_length_limit(4096))
.and(warp::body::json())
.and(with_state.clone())
.map(|req: ShareRequest, state: Arc<PoolState>| {
let nonce = match U512::from_str_radix(req.nonce.trim_start_matches("0x"), 16) {
Ok(n) => n,
Err(_) => {
return warp::reply::with_status(
warp::reply::json(&ShareResponse {
success: false,
token: None,
error: Some("malformed_nonce"),
block_found: false,
}),
warp::http::StatusCode::BAD_REQUEST,
)
}
};
match state.submit_share(&req.session_id, nonce) {
ShareOutcome::Accepted { token, block_found } => warp::reply::with_status(
warp::reply::json(&ShareResponse {
success: true,
token: Some(token),
error: None,
block_found,
}),
warp::http::StatusCode::OK,
),
ShareOutcome::Rejected(reason) => warp::reply::with_status(
warp::reply::json(&ShareResponse {
success: false,
token: None,
error: Some(reason),
block_found: false,
}),
warp::http::StatusCode::FORBIDDEN,
),
}
});
// POST /siteverify -> protected site's backend redeems a token
let siteverify = warp::path!("siteverify")
.and(warp::post())
.and(warp::body::content_length_limit(4096))
// Accept both JSON and form encoding, like the incumbents do.
.and(
warp::body::json()
.or(warp::body::form())
.unify()
.map(|req: SiteVerifyRequest| req),
)
.and(with_state.clone())
.map(|req: SiteVerifyRequest, state: Arc<PoolState>| {
match state.verify_token(&req.secret, &req.response) {
Ok(ts) => warp::reply::json(&SiteVerifyResponse {
success: true,
challenge_ts: Some(ts),
error_codes: vec![],
}),
Err(code) => warp::reply::json(&SiteVerifyResponse {
success: false,
challenge_ts: None,
error_codes: vec![code],
}),
}
});
// GET /api/stats -> pool counters
let stats = warp::path!("api" / "stats")
.and(warp::get())
.and(with_state.clone())
.map(|state: Arc<PoolState>| warp::reply::json(&state.stats()));
let cors = warp::cors()
.allow_any_origin()
.allow_methods(vec!["GET", "POST", "OPTIONS"])
.allow_headers(vec!["content-type"]);
let api = session.or(share).or(siteverify).or(stats);
log::info!("HTTP API listening on {}", addr);
if let Some(dir) = serve_dir {
log::info!("Serving demo statics from {}", dir.display());
let statics = warp::get().and(warp::fs::dir(dir));
warp::serve(api.or(statics).with(cors)).run(addr).await;
} else {
warp::serve(api.with(cors)).run(addr).await;
}
}
fn reply_error(
status: warp::http::StatusCode,
error: &'static str,
) -> warp::reply::WithStatus<warp::reply::Json> {
#[derive(Serialize)]
struct Err<'a> {
error: &'a str,
}
warp::reply::with_status(warp::reply::json(&Err { error }), status)
}

View File

@@ -0,0 +1,250 @@
//! Quantus captcha share pool.
//!
//! Sits between a quantus-node (external miner protocol) and browser captcha
//! solvers: hands out low-difficulty share challenges over the real block
//! header, verifies solves, mints single-use tokens for site backends, and
//! submits any share that happens to meet full network difficulty as a block.
mod http;
mod rate_limit;
mod state;
mod upstream;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use clap::Parser;
use primitive_types::U512;
use crate::rate_limit::SessionRateLimit;
#[derive(Parser, Debug)]
#[command(name = "pool-service", about = "Quantus captcha share pool")]
struct Args {
/// HTTP listen address for the captcha/siteverify API.
#[arg(long, default_value = "127.0.0.1:8787", env = "POOL_HTTP_ADDR")]
http_addr: SocketAddr,
/// Address of the quantus-node external-miner QUIC endpoint.
/// If omitted, runs in standalone mode with synthetic jobs (demo only).
#[arg(long, env = "POOL_NODE_ADDR")]
node_addr: Option<SocketAddr>,
/// Shared auth token from the node's `miner-auth-token` file.
/// Required when `--node-addr` is set. Prefer `--auth-token-file`.
#[arg(long, env = "POOL_AUTH_TOKEN", conflicts_with = "auth_token_file")]
auth_token: Option<String>,
/// Path to the node's `miner-auth-token` file (trimmed).
#[arg(long, env = "POOL_AUTH_TOKEN_FILE", conflicts_with = "auth_token")]
auth_token_file: Option<PathBuf>,
/// SHA-256 fingerprint of the node's miner TLS certificate (64 hex chars).
/// Required when `--node-addr` is set. Prefer `--tls-cert-sha256-file`.
#[arg(
long,
env = "POOL_TLS_CERT_SHA256",
conflicts_with = "tls_cert_sha256_file"
)]
tls_cert_sha256: Option<String>,
/// Path to the node's `miner-tls-cert-sha256` file.
#[arg(
long,
env = "POOL_TLS_CERT_SHA256_FILE",
conflicts_with = "tls_cert_sha256"
)]
tls_cert_sha256_file: Option<PathBuf>,
/// Share difficulty: expected number of hashes per captcha solve.
/// Measured browser WASM rate ≈ 120 kH/s on an M-series laptop, so
/// 50000 ≈ 0.4 s desktop / ~2 s phone. Raise for stronger rate limiting.
#[arg(long, default_value = "50000", env = "POOL_SHARE_DIFFICULTY")]
share_difficulty: u64,
/// Secret that protected-site backends must present to /siteverify.
#[arg(long, default_value = "dev-secret", env = "POOL_SITE_SECRET")]
site_secret: String,
/// Optional directory of static files to serve (demo page / widget).
#[arg(long, env = "POOL_SERVE_DIR")]
serve_dir: Option<PathBuf>,
/// Job rotation interval in standalone mode, seconds.
#[arg(long, default_value = "20")]
standalone_job_secs: u64,
/// Maximum live captcha sessions; /api/session returns 503 when full.
#[arg(long, default_value = "100000", env = "POOL_MAX_SESSIONS")]
max_sessions: usize,
/// Maximum live (unredeemed) share tokens; shares are refused when full.
#[arg(long, default_value = "100000", env = "POOL_MAX_TOKENS")]
max_tokens: usize,
/// Max /api/session issuances per client IP per minute.
#[arg(long, default_value = "60", env = "POOL_SESSIONS_PER_IP_PER_MIN")]
sessions_per_ip_per_min: u32,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let args = Args::parse();
anyhow::ensure!(args.share_difficulty > 0, "--share-difficulty must be > 0");
if args.site_secret == "dev-secret" {
log::warn!("Using default site secret; set --site-secret in production");
}
// Resolve + validate node auth before starting HTTP / upstream tasks so a
// bad fingerprint or oversized token exits instead of retrying forever.
let node_upstream = match args.node_addr {
Some(addr) => {
let auth_token = resolve_auth_token(args.auth_token, args.auth_token_file)?;
let tls_cert_sha256 =
resolve_tls_cert_sha256(args.tls_cert_sha256, args.tls_cert_sha256_file)?;
quic_transport::validate_auth_config(&auth_token, &tls_cert_sha256)?;
Some((addr, auth_token, tls_cert_sha256))
}
None => {
if args.auth_token.is_some()
|| args.auth_token_file.is_some()
|| args.tls_cert_sha256.is_some()
|| args.tls_cert_sha256_file.is_some()
{
log::warn!("Ignoring auth/TLS pin flags in standalone mode (no --node-addr)");
}
None
}
};
let (solution_tx, solution_rx) = tokio::sync::mpsc::channel(16);
let state = state::PoolState::new(
U512::from(args.share_difficulty),
args.site_secret.clone(),
solution_tx,
state::Limits {
max_sessions: args.max_sessions,
max_tokens: args.max_tokens,
},
);
let limiter = Arc::new(rate_limit::SessionIssuerLimiter::new(SessionRateLimit {
max_per_ip: args.sessions_per_ip_per_min,
window: Duration::from_secs(60),
}));
// Periodic cleanup of expired sessions/tokens and stale rate-limit windows.
{
let state = state.clone();
let limiter = limiter.clone();
tokio::spawn(async move {
loop {
tokio::time::sleep(Duration::from_secs(30)).await;
state.gc();
limiter.gc();
}
});
}
// Job source. Node mode reconnects across transient disconnects (node
// restart); only a PermanentConnectError ends the task — then we tear
// down HTTP so the process does not look healthy on a bad token/pin.
match node_upstream {
Some((addr, auth_token, tls_cert_sha256)) => {
log::info!("Upstream: quantus-node at {}", addr);
let state_for_upstream = state.clone();
let upstream = tokio::spawn(async move {
upstream::run_node_client(
state_for_upstream,
addr,
auth_token,
tls_cert_sha256,
solution_rx,
)
.await
});
tokio::select! {
biased;
result = upstream => {
match result {
Ok(Ok(())) => anyhow::bail!(
"upstream task exited unexpectedly (should reconnect forever)"
),
Ok(Err(e)) => {
log::error!("Upstream permanent failure; shutting down: {e}");
Err(e.into())
}
Err(e) => Err(anyhow::anyhow!("upstream task panicked: {e}")),
}
}
_ = http::serve(state, limiter, args.http_addr, args.serve_dir) => Ok(()),
}
}
None => {
log::warn!("No --node-addr given: running STANDALONE with synthetic jobs");
let state_for_upstream = state.clone();
tokio::spawn(async move {
upstream::run_standalone(
state_for_upstream,
Duration::from_secs(args.standalone_job_secs),
solution_rx,
)
.await
});
http::serve(state, limiter, args.http_addr, args.serve_dir).await;
Ok(())
}
}
}
fn resolve_auth_token(
auth_token: Option<String>,
auth_token_file: Option<PathBuf>,
) -> anyhow::Result<String> {
resolve_required_secret(
auth_token,
auth_token_file,
"--auth-token",
"--auth-token-file",
"when --node-addr is set, pass --auth-token-file <PATH> to the node's \
miner-auth-token file (or --auth-token <TOKEN>)",
)
}
fn resolve_tls_cert_sha256(value: Option<String>, file: Option<PathBuf>) -> anyhow::Result<String> {
resolve_required_secret(
value,
file,
"--tls-cert-sha256",
"--tls-cert-sha256-file",
"when --node-addr is set, pass --tls-cert-sha256-file <PATH> to the node's \
miner-tls-cert-sha256 file (or --tls-cert-sha256 <HEX>; also printed in node logs)",
)
}
fn resolve_required_secret(
value: Option<String>,
file: Option<PathBuf>,
value_flag: &str,
file_flag: &str,
missing_msg: &str,
) -> anyhow::Result<String> {
if let Some(value) = value {
let value = value.trim().to_string();
anyhow::ensure!(!value.is_empty(), "{value_flag} is empty");
return Ok(value);
}
if let Some(path) = file {
let contents = std::fs::read_to_string(&path)
.map_err(|e| anyhow::anyhow!("failed to read {file_flag} {}: {}", path.display(), e))?;
let value = contents.trim().to_string();
anyhow::ensure!(!value.is_empty(), "{file_flag} {} is empty", path.display());
return Ok(value);
}
anyhow::bail!("{missing_msg}");
}

View File

@@ -0,0 +1,104 @@
//! Per-client rate limiting for unauthenticated endpoints.
//!
//! `/api/session` is free, so a hard map cap alone still lets an attacker
//! churn entries at the TTL boundary. A per-IP issuance ceiling bounds that
//! even when the global cap has headroom.
use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::Mutex;
use std::time::{Duration, Instant};
#[derive(Debug, Clone, Copy)]
pub struct SessionRateLimit {
/// Max session issuances per IP per window.
pub max_per_ip: u32,
pub window: Duration,
}
impl Default for SessionRateLimit {
fn default() -> Self {
SessionRateLimit {
// 60 sessions/min/IP is generous for real users (a page load is
// one) but stops a single host from dominating issuance.
max_per_ip: 60,
window: Duration::from_secs(60),
}
}
}
#[derive(Default)]
struct IpWindow {
count: u32,
window_start: Option<Instant>,
}
pub struct SessionIssuerLimiter {
cfg: SessionRateLimit,
by_ip: Mutex<HashMap<IpAddr, IpWindow>>,
}
impl SessionIssuerLimiter {
pub fn new(cfg: SessionRateLimit) -> Self {
SessionIssuerLimiter {
cfg,
by_ip: Mutex::new(HashMap::new()),
}
}
/// Returns Ok(()) if this IP may issue another session, Err otherwise.
pub fn check(&self, ip: IpAddr) -> Result<(), &'static str> {
let now = Instant::now();
let mut map = self.by_ip.lock().unwrap();
let entry = map.entry(ip).or_default();
let window_start = entry.window_start.get_or_insert_with(Instant::now);
if now.duration_since(*window_start) >= self.cfg.window {
*window_start = now;
entry.count = 0;
}
if entry.count >= self.cfg.max_per_ip {
return Err("rate_limited");
}
entry.count += 1;
Ok(())
}
/// Drop stale IP windows so the side table doesn't grow forever.
pub fn gc(&self) {
let cutoff = self.cfg.window * 2;
let now = Instant::now();
self.by_ip.lock().unwrap().retain(|_, w| {
w.window_start
.map(|t| now.duration_since(t) < cutoff)
.unwrap_or(true)
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::{Ipv4Addr, Ipv6Addr};
#[test]
fn allows_up_to_cap_then_rejects() {
let lim = SessionIssuerLimiter::new(SessionRateLimit {
max_per_ip: 2,
window: Duration::from_secs(60),
});
let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1));
assert!(lim.check(ip).is_ok());
assert!(lim.check(ip).is_ok());
assert_eq!(lim.check(ip), Err("rate_limited"));
}
#[test]
fn ips_are_independent() {
let lim = SessionIssuerLimiter::new(SessionRateLimit {
max_per_ip: 1,
window: Duration::from_secs(60),
});
assert!(lim.check(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4))).is_ok());
assert!(lim.check(IpAddr::V6(Ipv6Addr::LOCALHOST)).is_ok());
}
}

View File

@@ -0,0 +1,527 @@
//! Core pool state: the current mining job, captcha sessions with disjoint
//! nonce ranges, and single-use share tokens.
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use primitive_types::U512;
use rand::RngCore;
/// How long a captcha session stays solvable after issuance.
pub const SESSION_TTL: Duration = Duration::from_secs(120);
/// How long a share token stays redeemable via /siteverify.
pub const TOKEN_TTL: Duration = Duration::from_secs(300);
/// Each session gets a disjoint slice of the nonce space this many nonces wide.
/// 2^64 nonces is unreachable within a session TTL at browser hash rates.
pub const SESSION_RANGE_BITS: u32 = 64;
/// Caps on live entries in the in-memory maps. `/api/session` is free and
/// unauthenticated, so without a ceiling a request flood becomes a memory-DoS
/// on the anti-abuse service itself. At the ~300 bytes/entry ballpark the
/// defaults bound each map to tens of MB.
#[derive(Debug, Clone, Copy)]
pub struct Limits {
pub max_sessions: usize,
pub max_tokens: usize,
}
impl Default for Limits {
fn default() -> Self {
Limits {
max_sessions: 100_000,
max_tokens: 100_000,
}
}
}
/// The job the pool is currently handing out to captcha sessions.
#[derive(Debug, Clone)]
pub struct Job {
/// Job id assigned by the upstream node (or synthesized in standalone mode).
pub job_id: String,
/// Block header hash to mine over.
pub header: [u8; 32],
/// Full network difficulty for this job (block found if a share meets it).
pub network_difficulty: U512,
}
/// A captcha session: one issued challenge, bound to a disjoint nonce range.
#[derive(Debug, Clone)]
pub struct Session {
pub id: String,
/// Snapshot of the job at issuance time. Shares are verified against this
/// header even if the pool has since moved to a newer job (grace period =
/// session TTL); only network-difficulty solutions for the *current* job
/// are submitted upstream.
pub job: Job,
pub nonce_start: U512,
pub nonce_end: U512, // exclusive
pub share_difficulty: U512,
pub issued_at: Instant,
pub solved: bool,
}
/// A verified share, redeemable exactly once by the protected site's backend.
#[derive(Debug, Clone)]
pub struct ShareToken {
pub created_at: Instant,
pub created_unix: u64,
pub consumed: bool,
}
#[derive(Debug, Default, Clone, serde::Serialize)]
pub struct PoolStats {
pub sessions_issued: u64,
pub shares_accepted: u64,
pub shares_rejected: u64,
pub tokens_verified: u64,
pub blocks_found: u64,
}
pub struct PoolState {
/// Current job, None until the first job arrives from the job source.
current_job: Mutex<Option<Job>>,
sessions: Mutex<HashMap<String, Session>>,
tokens: Mutex<HashMap<String, ShareToken>>,
stats: Mutex<PoolStats>,
/// Monotonic counter used to carve disjoint nonce ranges out of U512 space.
range_counter: AtomicU64,
/// Difficulty for captcha shares (expected hashes per solve).
pub share_difficulty: U512,
/// Site secret checked by /siteverify.
///
/// NOTE: single-tenant by design for now. All tokens are interchangeable
/// across any backend holding this secret; per-sitekey secrets land with
/// the host-registration follow-up.
pub site_secret: String,
limits: Limits,
/// Sender used to push network-difficulty solutions upstream.
pub solution_tx: tokio::sync::mpsc::Sender<FoundBlock>,
}
/// A share that met full network difficulty: a real block solution.
#[derive(Debug, Clone)]
pub struct FoundBlock {
pub job_id: String,
pub nonce: U512,
}
pub enum ShareOutcome {
/// Share accepted; token the client can hand to the protected site.
Accepted {
token: String,
block_found: bool,
},
Rejected(&'static str),
}
fn random_id() -> String {
let mut bytes = [0u8; 24];
rand::rng().fill_bytes(&mut bytes);
hex::encode(bytes)
}
fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
impl PoolState {
pub fn new(
share_difficulty: U512,
site_secret: String,
solution_tx: tokio::sync::mpsc::Sender<FoundBlock>,
limits: Limits,
) -> Arc<Self> {
assert!(
!share_difficulty.is_zero(),
"share difficulty must be non-zero"
);
Arc::new(PoolState {
current_job: Mutex::new(None),
sessions: Mutex::new(HashMap::new()),
tokens: Mutex::new(HashMap::new()),
stats: Mutex::new(PoolStats::default()),
range_counter: AtomicU64::new(0),
share_difficulty,
site_secret,
limits,
solution_tx,
})
}
/// Install a new job (from the upstream node or the standalone generator).
/// Existing sessions keep their job snapshot and remain solvable until TTL.
pub fn set_job(&self, job: Job) {
log::info!(
"New job {}: header=0x{} network_difficulty={}",
job.job_id,
hex::encode(job.header),
pow_core::format_u512(job.network_difficulty)
);
*self.current_job.lock().unwrap() = Some(job);
}
pub fn current_job(&self) -> Option<Job> {
self.current_job.lock().unwrap().clone()
}
/// Drop the current job (e.g. upstream disconnected). New sessions will
/// see `no_job_available` until the next `set_job`.
pub fn clear_job(&self) {
if self.current_job.lock().unwrap().take().is_some() {
log::info!("Cleared current job (upstream unavailable)");
}
}
/// Issue a new captcha session with a disjoint nonce range.
pub fn issue_session(&self) -> Result<Session, &'static str> {
let job = self.current_job().ok_or("no_job_available")?;
if self.sessions.lock().unwrap().len() >= self.limits.max_sessions {
return Err("at_capacity");
}
// Carve the next disjoint 2^64-nonce slice. The counter is 64 bits and
// slices are 2^64 wide, so slices live in the bottom 2^128 of the U512
// nonce space and never collide with each other.
let index = self.range_counter.fetch_add(1, Ordering::Relaxed);
let nonce_start = U512::from(index) << SESSION_RANGE_BITS;
let nonce_end = nonce_start + (U512::one() << SESSION_RANGE_BITS);
let session = Session {
id: random_id(),
job,
nonce_start,
nonce_end,
share_difficulty: self.share_difficulty,
issued_at: Instant::now(),
solved: false,
};
self.sessions
.lock()
.unwrap()
.insert(session.id.clone(), session.clone());
self.stats.lock().unwrap().sessions_issued += 1;
Ok(session)
}
/// Verify a submitted share and, if valid, mint a single-use token.
pub fn submit_share(&self, session_id: &str, nonce: U512) -> ShareOutcome {
let session = {
let mut sessions = self.sessions.lock().unwrap();
match sessions.get_mut(session_id) {
None => return self.reject("unknown_session"),
Some(s) => {
if s.solved {
return self.reject("session_already_solved");
}
if s.issued_at.elapsed() > SESSION_TTL {
sessions.remove(session_id);
return self.reject("session_expired");
}
// Mark solved optimistically; unmark on verification failure
// would allow retries, but a failed share means a buggy or
// malicious client, so we burn the session either way.
s.solved = true;
s.clone()
}
}
};
if nonce < session.nonce_start || nonce >= session.nonce_end {
return self.reject("nonce_out_of_range");
}
let (share_valid, hash) = pow_core::is_valid_nonce(
session.job.header,
nonce.to_big_endian(),
session.share_difficulty,
);
if !share_valid {
return self.reject("share_below_target");
}
// Jackpot check: does this share meet full network difficulty for the
// job it was issued against? Only submit if that job is still current.
// Target derivation delegates to pow-core's canonical JobContext.
let network_target =
pow_core::JobContext::new(session.job.header, session.job.network_difficulty).target;
let mut block_found = false;
if hash < network_target {
let still_current = self
.current_job()
.map(|j| j.job_id == session.job.job_id)
.unwrap_or(false);
if still_current {
log::info!(
"BLOCK FOUND by captcha share! job={} nonce={:x}",
session.job.job_id,
nonce
);
// This is the revenue event: count it and be loud if the
// upstream queue can't take it.
match self.solution_tx.try_send(FoundBlock {
job_id: session.job.job_id.clone(),
nonce,
}) {
Ok(()) => {
block_found = true;
self.stats.lock().unwrap().blocks_found += 1;
}
Err(e) => {
log::error!(
"DROPPED block solution for job {}: upstream queue unavailable ({})",
session.job.job_id,
e
);
}
}
}
}
let token = random_id();
{
let mut tokens = self.tokens.lock().unwrap();
// A full token map means someone is farming shares faster than
// TTL expiry; refuse rather than grow without bound. The client
// paid hashes for this, so make the refusal explicit.
if tokens.len() >= self.limits.max_tokens {
return self.reject("at_capacity");
}
tokens.insert(
token.clone(),
ShareToken {
created_at: Instant::now(),
created_unix: unix_now(),
consumed: false,
},
);
}
self.stats.lock().unwrap().shares_accepted += 1;
ShareOutcome::Accepted { token, block_found }
}
/// Redeem a share token (the reCAPTCHA/Turnstile "siteverify" semantics):
/// valid exactly once, only with the correct site secret.
pub fn verify_token(&self, secret: &str, token: &str) -> Result<u64, &'static str> {
if !constant_time_eq::constant_time_eq(secret.as_bytes(), self.site_secret.as_bytes()) {
return Err("invalid_secret");
}
let mut tokens = self.tokens.lock().unwrap();
match tokens.get_mut(token) {
None => Err("invalid_token"),
Some(t) if t.consumed => Err("token_already_consumed"),
Some(t) if t.created_at.elapsed() > TOKEN_TTL => {
tokens.remove(token);
Err("token_expired")
}
Some(t) => {
t.consumed = true;
let ts = t.created_unix;
self.stats.lock().unwrap().tokens_verified += 1;
Ok(ts)
}
}
}
pub fn stats(&self) -> PoolStats {
self.stats.lock().unwrap().clone()
}
/// Drop expired sessions and tokens. Called periodically.
pub fn gc(&self) {
self.sessions
.lock()
.unwrap()
.retain(|_, s| s.issued_at.elapsed() <= SESSION_TTL);
self.tokens
.lock()
.unwrap()
.retain(|_, t| t.created_at.elapsed() <= TOKEN_TTL);
}
fn reject(&self, reason: &'static str) -> ShareOutcome {
self.stats.lock().unwrap().shares_rejected += 1;
ShareOutcome::Rejected(reason)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_state(
share_difficulty: u64,
) -> (Arc<PoolState>, tokio::sync::mpsc::Receiver<FoundBlock>) {
let (tx, rx) = tokio::sync::mpsc::channel(4);
let state = PoolState::new(
U512::from(share_difficulty),
"secret".into(),
tx,
Limits::default(),
);
state.set_job(Job {
job_id: "1".into(),
header: [7u8; 32],
network_difficulty: U512::MAX, // effectively unreachable
});
(state, rx)
}
/// Brute-force a valid share within the session's range.
fn solve(session: &Session) -> U512 {
let mut nonce = session.nonce_start;
loop {
let (ok, _) = pow_core::is_valid_nonce(
session.job.header,
nonce.to_big_endian(),
session.share_difficulty,
);
if ok {
return nonce;
}
nonce += U512::one();
}
}
#[test]
fn accepts_valid_share_and_verifies_token_once() {
let (state, _rx) = test_state(2); // ~2 hashes per solve
let session = state.issue_session().unwrap();
let nonce = solve(&session);
let token = match state.submit_share(&session.id, nonce) {
ShareOutcome::Accepted { token, block_found } => {
assert!(!block_found);
token
}
ShareOutcome::Rejected(r) => panic!("rejected: {r}"),
};
assert!(state.verify_token("secret", &token).is_ok());
assert_eq!(
state.verify_token("secret", &token),
Err("token_already_consumed")
);
assert_eq!(state.verify_token("wrong", &token), Err("invalid_secret"));
}
#[test]
fn rejects_out_of_range_nonce() {
let (state, _rx) = test_state(1); // difficulty 1: every nonce is a valid hash
let session = state.issue_session().unwrap();
// A nonce outside the assigned slice must be rejected even though its
// hash trivially meets the share target.
let outside = session.nonce_end;
match state.submit_share(&session.id, outside) {
ShareOutcome::Rejected("nonce_out_of_range") => {}
other => panic!(
"expected out-of-range rejection, got {:?}",
matches_str(&other)
),
}
}
#[test]
fn burns_session_after_one_submission() {
let (state, _rx) = test_state(1);
let session = state.issue_session().unwrap();
let nonce = session.nonce_start;
assert!(matches!(
state.submit_share(&session.id, nonce),
ShareOutcome::Accepted { .. }
));
match state.submit_share(&session.id, nonce) {
ShareOutcome::Rejected("session_already_solved") => {}
other => panic!("expected burned session, got {:?}", matches_str(&other)),
}
}
#[test]
fn disjoint_ranges() {
let (state, _rx) = test_state(1);
let a = state.issue_session().unwrap();
let b = state.issue_session().unwrap();
assert!(a.nonce_end <= b.nonce_start || b.nonce_end <= a.nonce_start);
}
#[test]
fn block_found_signalled_when_share_meets_network_difficulty() {
let (tx, mut rx) = tokio::sync::mpsc::channel(4);
// Network difficulty == share difficulty == 2: any valid share is a block.
let state = PoolState::new(U512::from(2u64), "secret".into(), tx, Limits::default());
state.set_job(Job {
job_id: "1".into(),
header: [9u8; 32],
network_difficulty: U512::from(2u64),
});
let session = state.issue_session().unwrap();
let nonce = solve(&session);
match state.submit_share(&session.id, nonce) {
ShareOutcome::Accepted { block_found, .. } => assert!(block_found),
ShareOutcome::Rejected(r) => panic!("rejected: {r}"),
}
let block = rx
.try_recv()
.expect("block solution should be queued upstream");
assert_eq!(block.job_id, "1");
assert_eq!(block.nonce, nonce);
}
#[test]
fn session_cap_rejects_flood() {
let (tx, _rx) = tokio::sync::mpsc::channel(4);
let state = PoolState::new(
U512::from(1u64),
"secret".into(),
tx,
Limits {
max_sessions: 2,
max_tokens: 2,
},
);
state.set_job(Job {
job_id: "1".into(),
header: [7u8; 32],
network_difficulty: U512::MAX,
});
assert!(state.issue_session().is_ok());
assert!(state.issue_session().is_ok());
assert!(matches!(state.issue_session(), Err("at_capacity")));
}
#[test]
fn dropped_block_does_not_count_or_flag() {
// Zero-capacity channel: try_send always fails, simulating a stalled
// upstream. The share must still be accepted (the user did the work),
// but block_found must be false and blocks_found must stay 0.
let (tx, rx) = tokio::sync::mpsc::channel(1);
drop(rx); // closed channel -> try_send errors
let state = PoolState::new(U512::from(2u64), "secret".into(), tx, Limits::default());
state.set_job(Job {
job_id: "1".into(),
header: [9u8; 32],
network_difficulty: U512::from(2u64),
});
let session = state.issue_session().unwrap();
let nonce = solve(&session);
match state.submit_share(&session.id, nonce) {
ShareOutcome::Accepted { block_found, .. } => assert!(!block_found),
ShareOutcome::Rejected(r) => panic!("rejected: {r}"),
}
assert_eq!(state.stats().blocks_found, 0);
}
fn matches_str(o: &ShareOutcome) -> &'static str {
match o {
ShareOutcome::Accepted { .. } => "accepted",
ShareOutcome::Rejected(r) => r,
}
}
}

View File

@@ -0,0 +1,192 @@
//! Job sources: either a QUIC connection to a real quantus-node (speaking the
//! external miner protocol), or a standalone generator for demos/tests.
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use primitive_types::U512;
use quantus_miner_api::{
read_message, write_message, ApiResponseStatus, MinerMessage, MiningResult,
};
use tokio::sync::mpsc::Receiver;
use crate::state::{FoundBlock, Job, PoolState};
/// Connect to a node as an external miner and keep the pool's job current.
/// Solutions arriving on `solutions` are pushed upstream as JobResults.
///
/// Transient disconnects (node restart, network blip) clear the current job
/// and reconnect with backoff — the pool process stays up. Only
/// [`quic_transport::PermanentConnectError`] (bad pin / rejected auth) returns
/// `Err` so `main` can shut down the HTTP side.
pub async fn run_node_client(
state: Arc<PoolState>,
node_addr: SocketAddr,
auth_token: String,
tls_cert_sha256: String,
mut solutions: Receiver<FoundBlock>,
) -> Result<(), quic_transport::PermanentConnectError> {
let mut reconnect_delay = Duration::from_secs(1);
const MAX_RECONNECT_DELAY: Duration = Duration::from_secs(30);
// A block taken off the channel but not yet acknowledged by a successful
// upstream write. Kept across reconnects so a failed write doesn't drop
// the block (the node may still accept it after reconnection if the job
// hasn't moved on).
let mut pending: Option<FoundBlock> = None;
loop {
log::info!("Connecting to node at {}...", node_addr);
match quic_transport::connect(node_addr, &auth_token, &tls_cert_sha256).await {
Ok((connection, mut send, mut recv)) => {
log::info!("Connected to node at {}", node_addr);
let mut authenticated = false;
loop {
// Retry any unsubmitted block before doing anything else.
if let Some(block) = pending.take() {
match submit_block(&mut send, &block).await {
Ok(()) => {
log::info!(
"Submitted block solution to node (job {})",
block.job_id
);
}
Err(e) => {
log::error!("Failed to submit block solution, will retry: {}", e);
pending = Some(block);
break; // reconnect and retry
}
}
}
tokio::select! {
biased;
reason = connection.closed() => {
let msg = reason.to_string();
if !authenticated && msg.to_ascii_lowercase().contains("auth") {
state.clear_job();
return Err(quic_transport::PermanentConnectError(format!(
"node rejected miner auth ({msg}); check --auth-token / miner-auth-token"
)));
}
log::warn!("Node connection closed: {}", msg);
break;
}
// A captcha share met network difficulty: submit it.
block = solutions.recv() => {
if let Some(block) = block {
// Park it in `pending`; the top of the loop
// submits it and keeps it on write failure.
pending = Some(block);
}
}
msg = read_message(&mut recv) => {
match msg {
Ok(MinerMessage::NewJob(request)) => {
authenticated = true;
match parse_job(&request.job_id, &request.mining_hash, &request.difficulty) {
Ok(job) => state.set_job(job),
Err(e) => log::warn!("Ignoring malformed job from node: {}", e),
}
}
Ok(other) => {
log::warn!("Unexpected message from node: {:?}", other);
}
Err(e) => {
log::warn!("Read error from node: {}", e);
break;
}
}
}
}
}
// Node gone / stream dropped: stop handing out captchas on a
// stale header until we reconnect and get a fresh NewJob.
state.clear_job();
if authenticated {
reconnect_delay = Duration::from_secs(1);
}
}
Err(e) => {
if let Some(perm) = e.downcast_ref::<quic_transport::PermanentConnectError>() {
state.clear_job();
return Err(quic_transport::PermanentConnectError(perm.0.clone()));
}
log::warn!("Failed to connect to node: {}", e);
state.clear_job();
}
}
log::info!("Reconnecting in {:?}...", reconnect_delay);
tokio::time::sleep(reconnect_delay).await;
reconnect_delay = (reconnect_delay * 2).min(MAX_RECONNECT_DELAY);
}
}
async fn submit_block(send: &mut quinn::SendStream, block: &FoundBlock) -> std::io::Result<()> {
let result = MiningResult {
status: ApiResponseStatus::Completed,
job_id: block.job_id.clone(),
nonce: Some(format!("{:x}", block.nonce)),
work: Some(hex::encode(block.nonce.to_big_endian())),
hash_count: 0,
elapsed_time: 0.0,
miner_id: None,
};
write_message(send, &MinerMessage::JobResult(result)).await
}
fn parse_job(job_id: &str, mining_hash: &str, difficulty: &str) -> anyhow::Result<Job> {
let bytes = hex::decode(mining_hash)?;
let header: [u8; 32] = bytes
.try_into()
.map_err(|_| anyhow::anyhow!("mining_hash must be 32 bytes"))?;
let network_difficulty =
U512::from_dec_str(difficulty).map_err(|e| anyhow::anyhow!("bad difficulty: {:?}", e))?;
if network_difficulty.is_zero() {
anyhow::bail!("zero difficulty");
}
Ok(Job {
job_id: job_id.to_string(),
header,
network_difficulty,
})
}
/// Standalone mode: synthesize a fresh random job every `interval` so the
/// captcha works without a running node. Solutions are logged and dropped.
pub async fn run_standalone(
state: Arc<PoolState>,
interval: Duration,
mut solutions: Receiver<FoundBlock>,
) {
use rand::RngCore;
let mut counter: u64 = 0;
loop {
let mut header = [0u8; 32];
rand::rng().fill_bytes(&mut header);
counter += 1;
state.set_job(Job {
job_id: format!("standalone-{}", counter),
header,
// Unreachable "network" difficulty: standalone mode never finds blocks.
network_difficulty: U512::MAX,
});
tokio::select! {
_ = tokio::time::sleep(interval) => {}
block = solutions.recv() => {
if let Some(block) = block {
log::info!("(standalone) would submit block for job {}", block.job_id);
}
}
}
}
}

View File

@@ -34,6 +34,7 @@ gpu-interop = []
[dependencies]
primitive-types = { workspace = true }
qp-poseidon-core = { workspace = true }
qpow-math = { workspace = true }
[dev-dependencies]

View File

@@ -1,7 +1,28 @@
use primitive_types::U512;
use qp_poseidon_core::{Goldilocks, Poseidon2};
pub use qp_poseidon_core::SPONGE_WIDTH;
pub use qpow_math::{get_nonce_hash, is_valid_nonce, mine_range};
/// Sponge state (canonical u64 felts) after absorbing the 32-byte header and the
/// high 32 bytes of the big-endian nonce — the first two of the five Poseidon2
/// permutations of `get_nonce_hash`. This state is identical for every nonce in a
/// batch as long as incrementing the nonce never carries into its high 256 bits,
/// so GPU kernels can resume the sponge from here and skip 2 of 5 permutations.
pub fn mining_midstate(header: [u8; 32], nonce_high_be: [u8; 32]) -> [u64; SPONGE_WIDTH] {
let poseidon2 = Poseidon2::new();
let mut state = [Goldilocks::ZERO; SPONGE_WIDTH];
for (i, chunk) in header.chunks_exact(4).enumerate() {
state[i] += Goldilocks::from_u64(u32::from_le_bytes(chunk.try_into().unwrap()) as u64);
}
poseidon2.permute_mut(&mut state);
for (i, chunk) in nonce_high_be.chunks_exact(4).enumerate() {
state[i] += Goldilocks::from_u64(u32::from_le_bytes(chunk.try_into().unwrap()) as u64);
}
poseidon2.permute_mut(&mut state);
state.map(|g| g.as_canonical_u64())
}
/// Format a U512 in a human-readable way (scientific notation for large numbers).
pub fn format_u512(n: U512) -> String {
if n.is_zero() {
@@ -222,6 +243,39 @@ mod tests {
assert!(result.is_none());
}
#[test]
fn test_midstate_resumes_to_full_hash() {
let header = [7u8; 32];
let nonce = (U512::from(0xdeadbeefcafeu64) << 300) | U512::from(0x1234567890abcdefu64);
let nonce_be = nonce.to_big_endian();
let mut state =
mining_midstate(header, nonce_be[..32].try_into().unwrap()).map(Goldilocks::from_u64);
let poseidon2 = Poseidon2::new();
for (i, chunk) in nonce_be[32..].chunks_exact(4).enumerate() {
state[i] += Goldilocks::from_u64(u32::from_le_bytes(chunk.try_into().unwrap()) as u64);
}
poseidon2.permute_mut(&mut state);
state[0] += Goldilocks::ONE;
state[1] += Goldilocks::ONE;
poseidon2.permute_mut(&mut state);
let mut hash = [0u8; 64];
for i in 0..4 {
hash[i * 8..(i + 1) * 8].copy_from_slice(&state[i].as_canonical_u64().to_le_bytes());
}
poseidon2.permute_mut(&mut state);
for i in 0..4 {
hash[32 + i * 8..32 + (i + 1) * 8]
.copy_from_slice(&state[i].as_canonical_u64().to_le_bytes());
}
assert_eq!(
U512::from_big_endian(&hash),
qpow_math::get_nonce_hash(header, nonce_be)
);
}
#[test]
#[should_panic(expected = "division by zero")]
fn test_zero_difficulty_panics() {

View File

@@ -0,0 +1,15 @@
[package]
name = "quic-transport"
version.workspace = true
edition.workspace = true
publish = false
description = "Shared QUIC client transport for connecting to a quantus-node's external-miner endpoint"
[dependencies]
anyhow = { workspace = true }
quantus-miner-api = { workspace = true }
quinn = "0.10"
rustls = { version = "0.21", default-features = false, features = ["dangerous_configuration", "quic"] }
serde_json = { workspace = true }
sha2 = "0.10"
tokio = { workspace = true, features = ["macros", "time"] }

View File

@@ -0,0 +1,218 @@
//! Shared QUIC client used by miner-service and pool-service to connect to a
//! quantus-node's external-miner endpoint.
//!
//! The node uses a persisted self-signed certificate. Callers must supply the
//! expected SHA-256 fingerprint of that certificate (lowercase hex); the
//! transport rejects any other server cert.
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use quantus_miner_api::{write_message, MinerMessage, MAX_MESSAGE_SIZE};
use quinn::{ClientConfig, Endpoint};
use rustls::client::ServerCertVerified;
use sha2::{Digest, Sha256};
/// Permanent misconfiguration / auth failure: callers must not reconnect-loop.
#[derive(Debug)]
pub struct PermanentConnectError(pub String);
impl std::fmt::Display for PermanentConnectError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl std::error::Error for PermanentConnectError {}
/// Parse and validate miner auth inputs that never become valid by retrying.
///
/// Call this at process startup (before workers / HTTP) so a bad fingerprint
/// or oversized token exits immediately instead of looping forever.
pub fn validate_auth_config(auth_token: &str, tls_cert_sha256_hex: &str) -> anyhow::Result<()> {
parse_fingerprint(tls_cert_sha256_hex)?;
validate_ready_frame(auth_token)?;
Ok(())
}
/// Parse a 64-hex (optional whitespace/`:` separators) SHA-256 fingerprint.
pub fn parse_fingerprint(tls_cert_sha256_hex: &str) -> anyhow::Result<[u8; 32]> {
normalize_fingerprint(tls_cert_sha256_hex).ok_or_else(|| {
PermanentConnectError("invalid TLS cert SHA-256 fingerprint (expected 64 hex chars)".into())
.into()
})
}
fn validate_ready_frame(auth_token: &str) -> anyhow::Result<()> {
let frame = serde_json::to_vec(&MinerMessage::Ready {
token: auth_token.to_string(),
})
.map_err(|e| PermanentConnectError(format!("failed to serialize Ready frame: {e}")))?;
if frame.len() > MAX_MESSAGE_SIZE as usize {
return Err(PermanentConnectError(format!(
"auth token serializes to a {}-byte Ready frame; max is {} \
(shorten the token or avoid characters that require JSON escaping)",
frame.len(),
MAX_MESSAGE_SIZE
))
.into());
}
Ok(())
}
/// Establish a QUIC connection to the node's external-miner endpoint, open
/// the bidirectional stream, and send the initial `Ready { token }` message.
///
/// `tls_cert_sha256_hex` is the lowercase hex SHA-256 of the node's miner TLS
/// certificate DER (contents of the node's `miner-tls-cert-sha256` file).
///
/// Wrong tokens are rejected by the node closing with "auth failed"; that is
/// returned as [`PermanentConnectError`] so callers do not reconnect forever.
pub async fn connect(
addr: SocketAddr,
auth_token: &str,
tls_cert_sha256_hex: &str,
) -> anyhow::Result<(quinn::Connection, quinn::SendStream, quinn::RecvStream)> {
// Re-check here so library callers that skip startup validation still fail
// closed instead of looping on a deterministic parse error.
let expected = parse_fingerprint(tls_cert_sha256_hex)?;
validate_ready_frame(auth_token)?;
let mut crypto = rustls::ClientConfig::builder()
.with_safe_defaults()
.with_custom_certificate_verifier(Arc::new(PinnedCertVerifier { expected }))
.with_no_client_auth();
// Versioned with the wire protocol (node side: MINER_ALPN). `/2` = the
// authenticated `Ready { token }` protocol; a mismatched node/miner pair
// fails at the TLS handshake with "no application protocol" instead of an
// opaque auth/deserialize error.
crypto.alpn_protocols = vec![b"quantus-miner/2".to_vec()];
let mut client_config = ClientConfig::new(Arc::new(crypto));
let mut transport_config = quinn::TransportConfig::default();
transport_config.keep_alive_interval(Some(Duration::from_secs(5)));
transport_config.max_idle_timeout(Some(Duration::from_secs(15).try_into().unwrap()));
client_config.transport_config(Arc::new(transport_config));
let mut endpoint = Endpoint::client("0.0.0.0:0".parse().unwrap())?;
endpoint.set_default_client_config(client_config);
let connection = match endpoint.connect(addr, "localhost")?.await {
Ok(c) => c,
Err(e) => {
let msg = e.to_string();
// Wrong pin fails the TLS handshake deterministically.
if msg.to_ascii_lowercase().contains("fingerprint") {
return Err(PermanentConnectError(format!(
"TLS certificate pin rejected ({msg}); check --tls-cert-sha256 / \
miner-tls-cert-sha256"
))
.into());
}
return Err(e.into());
}
};
let (mut send, recv) = connection.open_bi().await?;
write_message(
&mut send,
&MinerMessage::Ready {
token: auth_token.to_string(),
},
)
.await?;
// Ready is fire-and-forget on the wire; the node closes immediately on a
// bad token. Wait briefly so that surfaces as a permanent error here
// instead of "connected" + 1s reconnect churn.
const AUTH_REJECT_GRACE: Duration = Duration::from_secs(2);
tokio::select! {
reason = connection.closed() => {
let msg = reason.to_string();
if msg.to_ascii_lowercase().contains("auth") {
return Err(PermanentConnectError(format!(
"node rejected miner auth ({msg}); check --auth-token / miner-auth-token"
))
.into());
}
return Err(anyhow::anyhow!("connection closed during auth handshake: {msg}"));
}
_ = tokio::time::sleep(AUTH_REJECT_GRACE) => {}
}
Ok((connection, send, recv))
}
fn normalize_fingerprint(input: &str) -> Option<[u8; 32]> {
let hex: String = input
.chars()
.filter(|c| !c.is_whitespace() && *c != ':')
.map(|c| c.to_ascii_lowercase())
.collect();
if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
let mut out = [0u8; 32];
for i in 0..32 {
out[i] = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok()?;
}
Some(out)
}
/// Accepts only a server certificate whose SHA-256 fingerprint matches.
struct PinnedCertVerifier {
expected: [u8; 32],
}
impl rustls::client::ServerCertVerifier for PinnedCertVerifier {
fn verify_server_cert(
&self,
end_entity: &rustls::Certificate,
_intermediates: &[rustls::Certificate],
_server_name: &rustls::ServerName,
_scts: &mut dyn Iterator<Item = &[u8]>,
_ocsp_response: &[u8],
_now: std::time::SystemTime,
) -> Result<ServerCertVerified, rustls::Error> {
let actual = Sha256::digest(&end_entity.0);
if actual.as_slice() != self.expected.as_slice() {
return Err(rustls::Error::General(
"server certificate SHA-256 fingerprint mismatch".into(),
));
}
Ok(ServerCertVerified::assertion())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_short_fingerprint() {
let err = validate_auth_config("token", "nope").unwrap_err();
assert!(
err.downcast_ref::<PermanentConnectError>().is_some(),
"{err}"
);
}
#[test]
fn accepts_valid_fingerprint_and_token() {
let fp = "a".repeat(64);
validate_auth_config("deadbeef", &fp).unwrap();
}
#[test]
fn rejects_token_whose_ready_frame_exceeds_limit() {
let fp = "a".repeat(64);
// Escape-heavy token: raw length under a naive cap can still blow the frame.
let token = "\"".repeat(600);
let err = validate_auth_config(&token, &fp).unwrap_err();
assert!(
err.downcast_ref::<PermanentConnectError>().is_some(),
"{err}"
);
assert!(err.to_string().contains("Ready frame"), "{err}");
}
}

View File

@@ -0,0 +1,13 @@
[package]
name = "solver-wasm"
version.workspace = true
edition.workspace = true
publish = false
description = "Browser WASM solver for Quantus captcha shares (raw C ABI, no wasm-bindgen)"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
primitive-types = { workspace = true }
qpow-math = { workspace = true }

View File

@@ -0,0 +1,101 @@
//! Browser captcha solver: grinds Poseidon2 nonce hashes over a block header.
//!
//! Exposed as a raw C-ABI WASM module (no wasm-bindgen) so it can be built
//! with a bare `cargo build --target wasm32-unknown-unknown` and loaded with
//! minimal JS glue. All I/O goes through one static buffer:
//!
//! ```text
//! offset 0..32 header hash (in)
//! offset 32..96 nonce, 64-byte big-endian (in: start; out: found/next)
//! offset 96..160 share target, 64-byte BE (in)
//! offset 160..224 winning hash, 64-byte BE (out, only when found)
//! ```
//!
//! JS calls `solve(iters)` in chunks (e.g. 520k iterations) to keep the
//! worker responsive; a return of 0 means "keep going" (the nonce field has
//! been advanced), 1 means the nonce field now holds a valid share.
use primitive_types::U512;
const HEADER_OFF: usize = 0;
const NONCE_OFF: usize = 32;
const TARGET_OFF: usize = 96;
const HASH_OFF: usize = 160;
const IO_SIZE: usize = 224;
static mut IO: [u8; IO_SIZE] = [0u8; IO_SIZE];
/// Pointer to the shared I/O buffer in WASM linear memory.
#[no_mangle]
pub extern "C" fn io_ptr() -> *mut u8 {
core::ptr::addr_of_mut!(IO) as *mut u8
}
/// Size of the shared I/O buffer, for JS-side sanity checks.
#[no_mangle]
pub extern "C" fn io_len() -> u32 {
IO_SIZE as u32
}
/// Grind up to `iters` nonces. Returns 1 if a share was found.
#[no_mangle]
pub extern "C" fn solve(iters: u32) -> u32 {
let io = unsafe { &mut *core::ptr::addr_of_mut!(IO) };
let mut header = [0u8; 32];
header.copy_from_slice(&io[HEADER_OFF..HEADER_OFF + 32]);
let mut nonce = U512::from_big_endian(&io[NONCE_OFF..NONCE_OFF + 64]);
let target = U512::from_big_endian(&io[TARGET_OFF..TARGET_OFF + 64]);
for _ in 0..iters {
let nonce_bytes = nonce.to_big_endian();
let hash = qpow_math::get_nonce_hash(header, nonce_bytes);
if hash < target {
io[NONCE_OFF..NONCE_OFF + 64].copy_from_slice(&nonce_bytes);
io[HASH_OFF..HASH_OFF + 64].copy_from_slice(&hash.to_big_endian());
return 1;
}
nonce = nonce.saturating_add(U512::one());
}
// Not found: persist progress so the next chunk resumes where we stopped.
io[NONCE_OFF..NONCE_OFF + 64].copy_from_slice(&nonce.to_big_endian());
0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn solve_matches_pool_verification() {
// Difficulty 4 -> target = MAX/4; expect a solution within a few iters.
let header = [7u8; 32];
let difficulty = U512::from(4u64);
let target = U512::MAX / difficulty;
let io = unsafe { &mut *core::ptr::addr_of_mut!(IO) };
io[HEADER_OFF..HEADER_OFF + 32].copy_from_slice(&header);
let start = U512::from(1u64) << 64; // arbitrary "session range" start
io[NONCE_OFF..NONCE_OFF + 64].copy_from_slice(&start.to_big_endian());
io[TARGET_OFF..TARGET_OFF + 64].copy_from_slice(&target.to_big_endian());
let mut found = 0;
for _ in 0..64 {
found = solve(256);
if found == 1 {
break;
}
}
assert_eq!(found, 1, "should find a share at difficulty 4");
let nonce = U512::from_big_endian(&io[NONCE_OFF..NONCE_OFF + 64]);
let (valid, hash) = qpow_math::is_valid_nonce(header, nonce.to_big_endian(), difficulty);
assert!(
valid,
"pool-side verification must accept the solver's nonce"
);
let reported = U512::from_big_endian(&io[HASH_OFF..HASH_OFF + 64]);
assert_eq!(hash, reported);
}
}

42
deploy/README.md Normal file
View File

@@ -0,0 +1,42 @@
# deploy
Everything the CI deploy of the miner ships or needs (`.gitea/workflows/deploy.yaml`).
Moved from `lair/quantus` on 2026-09-03 so that one repo owns the deployed miner;
the fleet's monitoring, GPU power limits and nvidia metrics stay there.
| File | Lands at |
| --- | --- |
| `quantus-miner.service` | `/etc/systemd/system/quantus-miner.service` |
| `quantus-miner.sysusers.conf` | `/etc/sysusers.d/quantus-miner.conf` |
| `quantus-miner-metrics.xml` | `/etc/firewalld/services/quantus-miner-metrics.xml` |
| `miner.env.tmpl` | rendered to `/etc/quantus-miner/miner.env` |
| `infra-setup.sh` | operator-run once per host; installs the `gitea_ci` sudoers |
The miner's credentials are generated by the node and copied host-to-host by the
deploy on every run. Nothing secret lives here.
## Traps, all previously hit
- **Adding a file or privileged command means re-running `infra-setup.sh`.** The
deploy preflights the host's `sudo -n -l` against the script's SUDO block and
names the missing paths, instead of failing partway with `sudo: a password is required`.
- **Deploys must be no-ops when nothing changed.** `push()` uses `rsync -ic`
(checksum). The artifact is rebuilt every run so mtimes always differ. Restart
only on an itemised content change.
- **`--node-addr` takes an IP, not a name.** It parses as a Rust `SocketAddr`
with no DNS. The deploy resolves the node on the miner host and renders the
literal; the `10.x` never enters the repo.
- **Pipes in validate steps.** The shell is `bash -e -o pipefail`; a reader that
exits early (`awk ... exit`, `grep -q`, `head`) SIGPIPEs the writer, exit 141.
Capture into a variable and parse with a here-string.
- **ssh argument quoting.** `run()` is `ssh ... "$@"` and the remote shell
re-splits; any argument with a space is passed as one pre-quoted string.
- **`PrivateDevices=false`** in the unit is deliberate: the miner needs `/dev/nvidia*`.
- **`systemctl is-active` is not evidence of mining.** A miner that found no
adapter, or never got a job, is `active`. Validate asserts `miner_hashes_total`
advances and `miner_gpu_devices` matches the matrix.
- **Binaries must be built on `cuda-13.0`** (Fedora 43, like the hosts). The
`rust` runner is Fedora 44; its binaries fail with `GLIBC_2.43 not found`.
- **A benchmark (`bench.yaml`) stops the miner on its host.** A deploy landing
on the same host during a measurement would find the unit down and start it
mid-window. Both are serialised only by not running them together.

135
deploy/infra-setup.sh Executable file
View File

@@ -0,0 +1,135 @@
#!/usr/bin/env bash
# One-time provisioning of a mining host for the CI deploy in
# .gitea/workflows/deploy.yaml (quantus/miner#8). The miner role of
# lair/quantus's script/infra-setup.sh, moved here so the deployer and its
# sudoers grant list live in the same repo and cannot drift: the deploy
# preflights the target's `sudo -n -l` against the SUDO block below.
#
# Convention: ~/git/architecture/deployment-gitea-actions.md §1§2. Run from a
# workstation with admin (sudo) ssh access to the targets — NOT the gitea_ci
# account. Idempotent; re-running is a no-op. Skips unreachable hosts.
#
# ./deploy/infra-setup.sh --pubkey ~/.ssh/id_gitea_ci.pub
# ./deploy/infra-setup.sh --pubkey ~/.ssh/id_gitea_ci.pub --miner-hosts benjy.hanzalova.internal
#
# The runner keypair is NOT generated here. It already exists at
# ~/.ssh/id_gitea_ci and is shared by every project's RSYNC_SSH_KEY secret and
# every host's gitea_ci authorized_keys — regenerating it would silently break
# every other deploy on the fleet.
#
# Re-run this whenever the deploy gains a new file to ship or a new privileged
# command; the preflight fails up front naming the missing paths rather than
# dying partway through an rsync with "sudo: a password is required".
set -euo pipefail
ADMIN_USER="${ADMIN_USER:-$USER}"
# Must agree with the deploy matrix in .gitea/workflows/deploy.yaml.
MINER_HOSTS="${MINER_HOSTS-benjy.hanzalova.internal quadbrat.hanzalova.internal beast.hanzalova.internal}"
PUBKEY=""
while [ $# -gt 0 ]; do
case "$1" in
--pubkey) PUBKEY="$2"; shift 2 ;;
--miner-hosts) MINER_HOSTS="$2"; shift 2 ;;
--admin) ADMIN_USER="$2"; shift 2 ;;
*) echo "unknown arg: $1" >&2; exit 2 ;;
esac
done
[ -n "$PUBKEY" ] && [ -r "$PUBKEY" ] || { echo "--pubkey <file> is required and must be readable" >&2; exit 2; }
provision_miner() {
local host="$1"
echo "== ${host} (miner) =="
if ! ssh -o ConnectTimeout=8 -o BatchMode=yes "${ADMIN_USER}@${host}" true; then
echo " ! unreachable as ${ADMIN_USER} — skipping" >&2
return 1
fi
# All privileged work in one remote `sudo bash`. The runner pubkey is the only
# dynamic value, passed as $1 (single line, no quoting hazard). The sudoers
# `\=` are the required escapes — visudo rejects a bare `=` in a command arg.
# The `*` in an rsync line matches rsync's --server arg vector; the trailing
# literal destination is what actually bounds the rule.
ssh "${ADMIN_USER}@${host}" "sudo bash -seu -- '$(cat "$PUBKEY")'" <<'REMOTE'
PUBKEY="$1"
# /bin/bash, NOT nologin. The deploy runs `ssh gitea_ci@host <command>`, and
# a nologin shell refuses that with "This account is currently not
# available": the key authenticates, then the command cannot run.
if ! getent passwd gitea_ci >/dev/null; then
useradd --system --create-home --home-dir /var/lib/gitea_ci \
--shell /bin/bash gitea_ci
echo " + created gitea_ci"
else
echo " = gitea_ci already present"
fi
cur=$(getent passwd gitea_ci | cut -d: -f7)
if [ "$cur" != /bin/bash ]; then
usermod -s /bin/bash gitea_ci
echo " ~ gitea_ci shell was ${cur} — set to /bin/bash so ssh commands can run"
fi
install -d -o gitea_ci -g gitea_ci -m 0700 /var/lib/gitea_ci/.ssh
ak=/var/lib/gitea_ci/.ssh/authorized_keys
touch "$ak"
grep -qxF "$PUBKEY" "$ak" || printf '%s\n' "$PUBKEY" >> "$ak"
chown gitea_ci:gitea_ci "$ak"
chmod 0600 "$ak"
usermod -aG systemd-journal gitea_ci
# The GPU must be present and enumerable before a miner deploy is worth
# attempting; failing here beats a green deploy that mines nothing.
if ! command -v nvidia-smi >/dev/null; then
echo " ! nvidia-smi not found — this host has no usable NVIDIA driver" >&2
exit 1
fi
nvidia-smi --query-gpu=name --format=csv,noheader | sed 's/^/ = gpu: /'
tmp=/etc/sudoers.d/.quantus-miner_gitea_ci.tmp
cat > "$tmp" <<'SUDO'
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /usr/local/bin/quantus-miner
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/systemd/system/quantus-miner.service
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/sysusers.d/quantus-miner.conf
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/quantus-miner/miner.env
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/quantus-miner/miner-auth-token
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/quantus-miner/miner-tls-cert-sha256
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/firewalld/services/quantus-miner-metrics.xml
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --get-default-zone
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --reload
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --permanent --zone\=* --add-rich-rule\=*
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --zone\=* --add-rich-rule\=*
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --zone\=* --query-rich-rule\=*
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemd-sysusers
gitea_ci ALL=(root) NOPASSWD: /usr/bin/install -d -o root -g quantus-miner -m 0750 /etc/quantus-miner
gitea_ci ALL=(root) NOPASSWD: /usr/bin/install -d -o quantus-miner -g quantus-miner -m 0750 /var/lib/quantus-miner
gitea_ci ALL=(root) NOPASSWD: /usr/sbin/restorecon -R /usr/local/bin/quantus-miner /etc/quantus-miner /var/lib/quantus-miner
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl daemon-reload
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl enable quantus-miner.service
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl restart quantus-miner.service
# stop/start (not just restart) so the benchmark harness (bench.yaml) can pause
# mining for a measurement window and resume it afterwards.
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl stop quantus-miner.service
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl start quantus-miner.service
# Rollback: the deploy keeps the previous binary as .prev (cp) and restores it
# with install when validate fails; cp back would hit "Text file busy" on the
# running binary, install unlinks the destination first. Deploying from main means a bad commit reaches production;
# this is what makes that survivable.
gitea_ci ALL=(root) NOPASSWD: /usr/bin/cp -p /usr/local/bin/quantus-miner /usr/local/bin/quantus-miner.prev
gitea_ci ALL=(root) NOPASSWD: /usr/bin/install -m 0755 /usr/local/bin/quantus-miner.prev /usr/local/bin/quantus-miner
SUDO
chmod 0440 "$tmp"
visudo -cf "$tmp"
mv "$tmp" /etc/sudoers.d/quantus-miner_gitea_ci
echo " = sudoers quantus-miner_gitea_ci installed and visudo-verified"
REMOTE
}
rc=0
for h in $MINER_HOSTS; do
provision_miner "$h" || rc=1
done
echo
echo "done. remaining operator steps:"
echo " 1. confirm RSYNC_SSH_KEY is set in the repo's Actions secrets"
echo " 2. push to main, or run the deploy workflow manually (mode: validate first)"
exit $rc

6
deploy/miner.env.tmpl Normal file
View File

@@ -0,0 +1,6 @@
# Rendered by .gitea/workflows/deploy.yaml (quantus/miner) and rsynced to
# /etc/quantus-miner/miner.env (0640 root:quantus-miner). Not secret — the
# miner's actual credentials are the auth token and TLS pin, which are copied
# from the node host as separate 0640 files.
QUANTUS_NODE_ADDR={{QUANTUS_NODE_ADDR}}
QUANTUS_GPU_DEVICES={{QUANTUS_GPU_DEVICES}}

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<service>
<short>quantus-miner-metrics</short>
<description>Quantus miner Prometheus exporter. The miner binds this on
0.0.0.0 unconditionally (crates/metrics: SocketAddr from [0,0,0,0]) — there is
no loopback option — so the firewalld rich rule scoped to the scrape host is
the only thing bounding who can reach it.</description>
<port protocol="tcp" port="9900"/>
</service>

View File

@@ -0,0 +1,64 @@
# Quantus external miner. Runs on the GPU host and connects OUT to the node's
# QUIC control channel; it listens on nothing but its loopback metrics port, so
# it ships no firewalld service of its own.
#
# Hardened per ~/git/architecture/generic.md §8, with one deliberate relaxation:
#
# PrivateDevices=false
# The miner needs /dev/nvidia*; PrivateDevices=true hides them and wgpu
# finds no adapter, silently falling back to nothing.
#
# Poseidon2-over-Goldilocks is compute-bound in registers, not memory-bound, so
# there is no tuning knob here worth more than the GPU power limit — which is a
# host-level concern (nvidia-smi -pl), not a unit-file one.
#
# --cpu-workers 0 is deliberate: a current-gen desktop CPU contributes ~4 MH/s
# next to a 4090's ~183 MH/s, for a couple hundred watts. Measured, not assumed.
[Unit]
Description=Quantus external miner (GPU)
Documentation=https://github.com/Quantus-Network/quantus-miner
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=quantus-miner
Group=quantus-miner
Environment=RUST_LOG=info
WorkingDirectory=/var/lib/quantus-miner
ExecStart=/usr/local/bin/quantus-miner serve \
--node-addr ${QUANTUS_NODE_ADDR} \
--auth-token-file /etc/quantus-miner/miner-auth-token \
--tls-cert-sha256-file /etc/quantus-miner/miner-tls-cert-sha256 \
--gpu-devices ${QUANTUS_GPU_DEVICES} \
--cpu-workers 0 \
--metrics-port 9900
EnvironmentFile=/etc/quantus-miner/miner.env
# The node may be down, still syncing, or mid-redeploy; reconnecting is normal
# operation, not an error condition.
Restart=always
RestartSec=15s
LimitNOFILE=65536
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=false
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictRealtime=true
RestrictSUIDSGID=true
LockPersonality=true
SystemCallArchitectures=native
MemoryDenyWriteExecute=false
ReadWritePaths=/var/lib/quantus-miner
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,4 @@
#Type Name ID GECOS Home directory Shell
u quantus-miner - "Quantus miner service account" /var/lib/quantus-miner /usr/sbin/nologin
m quantus-miner video
m quantus-miner render

View File

@@ -14,8 +14,8 @@
#
# Notes:
# - CPUAffinity must be a subset of the services allowed cpuset (e.g., cgroups).
# - The miner logs the detected cpuset mask at startup and, when metrics are enabled,
# exports a miner_effective_cpus gauge for dashboards.
# - The miner counts CPUs via the process affinity mask and exports a
# miner_effective_cpus gauge on the always-on metrics endpoint.
# - Adjust the CPU list and worker count to match your hardware and cpuset.
#
# Example below assumes an 8way logical CPU system (IDs 07).
@@ -40,9 +40,8 @@ IOSchedulingPriority=4
# Default time-sharing policy is fine for CPU-bound workloads when pinned.
CPUSchedulingPolicy=other
# Optional: pin worker count and enable metrics (uncomment and adjust as needed).
# Environment=MINER_ENGINE=cpu-fast
# Environment=MINER_WORKERS=4
# Optional: pin worker count and change the metrics port (uncomment and adjust as needed).
# Environment=MINER_CPU_WORKERS=4
# Environment=MINER_METRICS_PORT=9900
# Environment=RUST_LOG=info,miner=debug

View File

@@ -15,11 +15,11 @@
# - CPUAffinity MUST be a subset of the services allowed cpuset (cgroup v2:
# /sys/fs/cgroup/cpuset.cpus.effective). If your deployment already constrains
# the miner with a cpuset, you can omit CPUAffinity here to inherit that mask.
# - The miner logs the detected cpuset mask at startup (debug) and, when metrics
# are enabled, exports miner_effective_cpus for dashboards.
# - Set MINER_WORKERS to match the number of CPUs in your affinity mask if you want
# full utilization; otherwise the miner default (50% of effective CPUs) is used
# when --workers is omitted.
# - The miner counts CPUs via the process affinity mask and exports
# miner_effective_cpus on the always-on metrics endpoint.
# - Set MINER_CPU_WORKERS to match the number of CPUs in your affinity mask if you
# want full utilization; otherwise the miner auto-detects when --cpu-workers is
# omitted.
[Service]
# Bind miner to ALL logical CPUs intended for mining. EDIT this to match your host.
@@ -46,10 +46,9 @@ IOSchedulingPriority=0
TasksMax=infinity
MemoryMax=infinity
# Optional: pin worker count and enable metrics (uncomment and adjust).
# Ensure MINER_WORKERS matches the size of CPUAffinity (or the cpuset mask) to use all CPUs.
# Environment=MINER_ENGINE=cpu-fast
# Environment=MINER_WORKERS=16
# Optional: pin worker count and change the metrics port (uncomment and adjust).
# Ensure MINER_CPU_WORKERS matches the size of CPUAffinity (or the cpuset mask) to use all CPUs.
# Environment=MINER_CPU_WORKERS=16
# Environment=MINER_METRICS_PORT=9900
# Environment=RUST_LOG=info,miner=debug
@@ -64,5 +63,5 @@ MemoryMax=infinity
# and compare with:
# cat /sys/fs/cgroup/cpuset.cpus.effective
#
# - The service logs the cpuset mask at startup and the metrics exporter (if enabled)
# exposes miner_effective_cpus for observability.
# - The always-on metrics exporter exposes miner_effective_cpus for
# observability of the detected CPU count (MINER_METRICS_PORT changes the port).

View File

@@ -15,14 +15,24 @@
#
# Notes:
# - The miner CLI supports environment variables for all flags (see README):
# MINER_PORT, MINER_METRICS_PORT, MINER_WORKERS, MINER_ENGINE,
# MINER_PROGRESS_CHUNK_MS,
# MINER_MANIP_SOLVED_BLOCKS, MINER_MANIP_BASE_DELAY_NS,
# MINER_MANIP_STEP_BATCH, MINER_MANIP_THROTTLE_CAP.
# MINER_NODE_ADDR, MINER_AUTH_TOKEN_FILE (or MINER_AUTH_TOKEN),
# MINER_TLS_CERT_SHA256_FILE (or MINER_TLS_CERT_SHA256),
# MINER_CPU_WORKERS, MINER_GPU_DEVICES, MINER_GPU_BATCH_SIZE,
# MINER_CPU_BATCH_SIZE, MINER_GPU_THROTTLE_MS, MINER_METRICS_PORT,
# MINER_ALLOW_INTEGRATED, MINER_VERBOSE.
# This unit relies on those env vars (set in /etc/default|/etc/sysconfig) so
# ExecStart can remain stable and simple.
# - The service logs the detected cpuset mask (if any) and emits a
# miner_effective_cpus metric when metrics are enabled.
# - The node requires miner auth: MINER_AUTH_TOKEN_FILE and
# MINER_TLS_CERT_SHA256_FILE (or their inline variants) are mandatory —
# the miner exits at startup without them. Copy the node's
# miner-auth-token and miner-tls-cert-sha256 files somewhere this service
# can read (e.g. /etc/quantus-miner/): ProtectHome=true below blocks
# paths under /home and /root, where the node's base path usually lives.
# - The miner counts CPUs via the process affinity mask (so CPUAffinity=
# is respected) and emits a miner_effective_cpus metric.
# - The Prometheus exporter is ALWAYS on and binds plaintext HTTP on
# 0.0.0.0:9900 by default; MINER_METRICS_PORT only changes the port.
# Firewall the port if the host is reachable from untrusted networks.
[Unit]
Description=Quantus External Miner Service
@@ -53,7 +63,9 @@ Environment=RUST_LOG=info
# Launch the miner. All configuration should be passed via environment vars
# (preferred) or via EXTRA_MINER_FLAGS in the env file for additional arguments.
ExecStart=/usr/local/bin/quantus-miner $EXTRA_MINER_FLAGS
# MINER_AUTH_TOKEN_FILE and MINER_TLS_CERT_SHA256_FILE must be set in the env
# file (see readme.md) or the miner exits immediately.
ExecStart=/usr/local/bin/quantus-miner serve $EXTRA_MINER_FLAGS
# Graceful shutdown and restart behavior
Restart=always

View File

@@ -18,6 +18,18 @@ Prerequisites
- The quantus-miner binary installed at /usr/local/bin/quantus-miner (or adjust ExecStart).
- A service account (recommended):
- sudo useradd --system --no-create-home --shell /usr/sbin/nologin quantus
- The node's miner auth token and TLS cert fingerprint (required since the node
authenticates miners). The node creates both on first start with
--miner-listen-port and logs their paths (default:
`<base-path>/chains/<chain>/miner-auth-token` and
`<base-path>/chains/<chain>/miner-tls-cert-sha256`). Copy them where the
service can read them — the unit sets ProtectHome=true, so paths under /home
or /root are not readable:
- sudo install -d -m 0755 /etc/quantus-miner
- sudo install -m 0640 -o root -g quantus \
"<base-path>/chains/<chain>/miner-auth-token" /etc/quantus-miner/miner-auth-token
- sudo install -m 0644 \
"<base-path>/chains/<chain>/miner-tls-cert-sha256" /etc/quantus-miner/miner-tls-cert-sha256
Install (unit)
1) Copy the service file
@@ -26,21 +38,22 @@ Install (unit)
2) Create a writable working directory (managed by systemd via StateDirectory)
sudo install -d -o quantus -g quantus /var/lib/quantus-miner
3) (Optional) Provide environment variables
3) Provide environment variables (auth vars are REQUIRED)
- Debian/Ubuntu: sudoedit /etc/default/quantus-miner
- RHEL/CentOS/Fed: sudoedit /etc/sysconfig/quantus-miner
Common variables (examples):
MINER_ENGINE=cpu
MINER_PORT=9833
MINER_METRICS_PORT=9900 # enable Prometheus exporter
MINER_WORKERS=4 # leave unset to use default (50% of effective CPUs)
MINER_PROGRESS_CHUNK_MS=2000
# Throttling engine (cpu-chain-manipulator) knobs:
# MINER_MANIP_SOLVED_BLOCKS=0
# MINER_MANIP_BASE_DELAY_NS=500000
# MINER_MANIP_STEP_BATCH=10000
# MINER_MANIP_THROTTLE_CAP=0
Common variables (examples). Note: systemd EnvironmentFile= does not strip
inline comments — keep each assignment on its own line with nothing after
the value.
MINER_NODE_ADDR=127.0.0.1:9833
# Required: node auth token + TLS cert pin (see Prerequisites)
MINER_AUTH_TOKEN_FILE=/etc/quantus-miner/miner-auth-token
MINER_TLS_CERT_SHA256_FILE=/etc/quantus-miner/miner-tls-cert-sha256
# CPU worker threads; leave unset to auto-detect (~50% of available CPUs)
MINER_CPU_WORKERS=4
MINER_GPU_DEVICES=0
# Prometheus exporter port (the exporter is always on; this only changes the port)
MINER_METRICS_PORT=9900
# Extra CLI flags (kept stable ExecStart):
# EXTRA_MINER_FLAGS="--some-future-flag value"
@@ -65,31 +78,44 @@ Dedicated hardware (miner only)
sudo systemctl restart quantus-miner
Configuration reference (environment variables)
- MINER_ENGINE
- cpu (default), cpu-chain-manipulator
- gpu for high-performance GPU mining
- MINER_PORT
- HTTP API port (default 9833)
- MINER_NODE_ADDR
- Address of the node's miner QUIC endpoint (default 127.0.0.1:9833).
- MINER_AUTH_TOKEN_FILE (required, or MINER_AUTH_TOKEN inline)
- Path to a copy of the node's miner-auth-token file. The file variant is
preferred so the secret stays off the command line and out of `systemctl show`.
- MINER_TLS_CERT_SHA256_FILE (required, or MINER_TLS_CERT_SHA256 inline)
- Path to a copy of the node's miner-tls-cert-sha256 file (the fingerprint is
also printed in the node's startup logs).
- MINER_CPU_WORKERS
- CPU worker threads. If unset, auto-detected.
- MINER_GPU_DEVICES
- Number of GPU devices to use. If unset, auto-detected.
- MINER_GPU_BATCH_SIZE / MINER_CPU_BATCH_SIZE
- Nonces/hashes per cancellation check (defaults 1000000 / 10000).
- MINER_GPU_THROTTLE_MS
- Delay between GPU batches in milliseconds (default 0 = no throttle).
- MINER_METRICS_PORT
- Enable Prometheus exporter when set (e.g., 9900). If unset, metrics exporter is disabled.
- MINER_WORKERS
- Worker threads (logical CPUs). If unset, defaults to ~50% of effective CPUs (clamped to [1, effective-1]).
- MINER_PROGRESS_CHUNK_MS
- Target milliseconds for per-thread progress updates (default 2000ms).
- Throttling engine (cpu-chain-manipulator) knobs
- MINER_MANIP_SOLVED_BLOCKS, MINER_MANIP_BASE_DELAY_NS, MINER_MANIP_STEP_BATCH, MINER_MANIP_THROTTLE_CAP
- Prometheus exporter port (default 9900). The exporter is ALWAYS on and
binds plaintext HTTP on all interfaces (0.0.0.0); this variable only
changes the port — there is no disable or loopback-only option. Firewall
the port or restrict it to your monitoring network.
- MINER_ALLOW_INTEGRATED
- Allow integrated GPUs even when discrete GPUs are present.
- EXTRA_MINER_FLAGS
- Optional extra CLI flags appended to ExecStart.
CPU affinity, cpusets, and workers
- The miner detects the effective CPU capacity (logical CPUs) visible to the process by preferring cgroup v2 cpuset (cpuset.cpus.effective), falling back to v1, else using all logical CPUs.
- At startup (debug level), the miner logs the detected cpuset mask (if any).
- A Prometheus gauge miner_effective_cpus is emitted (when metrics are enabled) with the effective count for dashboards/alerts.
- If --workers (or MINER_WORKERS) exceeds effective CPUs, it is clamped and a warning is logged.
- If omitted, the miner defaults to ~50% of effective CPUs (but always at least 1 and less than or equal to effective-1).
- The miner counts available CPUs via the process CPU affinity mask (num_cpus),
so a systemd CPUAffinity= setting (or a cgroup cpuset) is reflected in the count.
- If MINER_CPU_WORKERS is unset, the miner uses ~50% of the detected CPUs
(at least 1) and logs the choice at startup ("Auto-detected N CPU workers").
- An explicit MINER_CPU_WORKERS value is used as-is — it is NOT clamped to the
affinity mask, so an oversized value oversubscribes the pinned CPUs.
- A Prometheus gauge miner_effective_cpus is emitted with the detected count for dashboards/alerts.
- When pinning CPUAffinity at the systemd level:
- Ensure CPUAffinity is a subset of the cgroup cpuset mask.
- Consider setting MINER_WORKERS to match the number of CPUs in the affinity mask if you want full utilization, or rely on the default 50% policy.
- Set MINER_CPU_WORKERS to the number of CPUs in the affinity mask for full
utilization, or leave it unset for the ~50% default.
Security hardening (in the unit)
- NoNewPrivileges=true
@@ -101,6 +127,9 @@ Security hardening (in the unit)
- RestrictSUIDSGID=true
- SystemCallFilter=@system-service
Adjust or relax as needed for your environment.
Note: the Prometheus exporter always listens on 0.0.0.0:<MINER_METRICS_PORT>
(default 9900) — the unit cannot disable it or bind it to loopback. Firewall
the port if the host is reachable from untrusted networks.
Validation and troubleshooting
- Check service status and logs:
@@ -110,20 +139,28 @@ Validation and troubleshooting
taskset -cp "$pid"
- Verify cpuset mask (cgroup v2):
cat /sys/fs/cgroup/cpuset.cpus.effective
- Metrics:
- If MINER_METRICS_PORT is set, curl http://127.0.0.1:<port>/metrics
- Metrics (always on, default port 9900):
- curl http://127.0.0.1:${MINER_METRICS_PORT:-9900}/metrics
- Look for miner_effective_cpus and per-job/thread metrics.
- Common pitfalls:
- ExecStart path wrong (ensure /usr/local/bin/quantus-miner exists and is executable).
- Service user/group missing (create quantus or adjust User/Group).
- Miner exits immediately with "miner auth token required" / "TLS cert fingerprint required":
set MINER_AUTH_TOKEN_FILE and MINER_TLS_CERT_SHA256_FILE in the env file (see Prerequisites).
- Auth/TLS files unreadable: ProtectHome=true blocks /home and /root; copy the
files to /etc/quantus-miner/ and make them readable by the quantus user.
- Node rejects the miner after the node's base path changed or its credential
files (miner-auth-token, miner-tls-cert.der/-key.der) were deleted and
regenerated: re-copy both files and restart. (A plain purge-chain does NOT
rotate them — it removes only the database, so the same token and cert are
reloaded on the next start.)
- CPUAffinity not a subset of the cgroup cpuset (adjust cpuset or affinity).
- MINER_ENGINE set to gpu-* (currently unimplemented, exits with clear error).
- Insufficient permissions to write WorkingDirectory (systemd StateDirectory creates /var/lib/quantus-miner with correct ownership).
Operational tips
- For shared machines: prefer 10-shared-hardware.conf and leave MINER_WORKERS unset (defaults to ~50%).
- For dedicated machines: use 20-dedicated-hardware.conf and set MINER_WORKERS to the number of CPUs in CPUAffinity (or omit CPUAffinity to inherit cpuset).
- Use RUST_LOG=info,miner=debug temporarily to verify startup detection (cpuset mask, effective CPUs) and to observe mining loop behavior; then turn back down to reduce log volume.
- For shared machines: prefer 10-shared-hardware.conf and leave MINER_CPU_WORKERS unset (auto-detect).
- For dedicated machines: use 20-dedicated-hardware.conf and set MINER_CPU_WORKERS to the number of CPUs in CPUAffinity (or omit CPUAffinity to inherit cpuset).
- Use RUST_LOG=info,miner=debug temporarily to verify startup detection (worker auto-detection, GPU discovery) and to observe mining loop behavior; then turn back down to reduce log volume.
Support
- Repository: https://github.com/Quantus-Network/quantus-miner

View File

@@ -1,4 +1,4 @@
[toolchain]
channel = "stable"
channel = "1.93.0"
components = ["clippy", "rustfmt"]
profile = "minimal"