81 Commits
Author SHA1 Message Date
Nikolaus Hegerandn13 cb6deb9139 bump version to v4.0.2 (#93)
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 Hegerandn13 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
illuzenandn13 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
illuzenandCursor 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 HegerandClaude Fable 5 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 HegerandClaude Fable 5 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 HegerandClaude Fable 5 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 HegerandClaude Fable 5 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 HegerandClaude Fable 5 0c61e3da34 Add security policy
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 12:11:38 +08:00
illuzenandCursor 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
illuzen 15edf4a806 bump version to v3.3.1 (#73) 2026-06-30 16:34:53 +08:00
illuzen a47db3d1a2 Fix windows again (#72)
* handle lost device correctly

* Update lib.rs

* allow-integrated flag otherwise exclude

* DeviceLost is a status

* Integrated GPU skipped when discrete init fails

* nits
2026-06-30 16:30:11 +08:00
illuzen 8359892406 bump version to v3.3.0 (#71) 2026-06-30 13:47:10 +08:00
Nikolaus Hegerandilluzen d99be29a64 Select GPU adapters by backend to fix multi-backend OOM (#67)
On Windows wgpu enumerates each physical GPU once per backend (Vulkan +
DX12) plus a CPU-emulated fallback ("Microsoft Basic Render Driver").
Building a mining context for every entry causes VRAM contention and
OOMs the process during benchmark/serve startup (#61).

Instead of deduplicating by (vendor, device) PCI IDs - which would
collapse rigs with multiple identical cards into a single context - drop
CPU-emulated adapters and keep all adapters from the highest-ranked
backend present (Vulkan/Metal, then DX12). Within a single backend each
physical GPU appears exactly once, so identical cards are preserved by
construction and no physical-ID matching is needed.

Selected adapters are ordered discrete-first so `--gpu-devices 1` picks
the discrete card on hybrid laptops. Skipped adapters are logged at info
level; if nothing usable remains, init fails with an explicit error.

Selection logic is a pure index-based function unit-tested against the
exact enumeration reported in #61, identical multi-GPU rigs, DX12-only
machines, and software-only environments.

Co-authored-by: illuzen <illuzen@users.noreply.github.com>
2026-06-30 13:13:45 +08:00
illuzen 7296c003bb Improve Windows gpu support (#70)
* support AMD GPUs on windows

* add support for more GPUs

* remove shader dump from logs

* handle gpu initialization failure better

* fmt

* re-order matching to avoid mobile / desktop false positives

* harden gpu matching

* add tests to engine-gpu module

* handle dead device and failed mapping correctly

* handle timeout properly with tokio

* nits

* fix benchmarks

* fmt
2026-06-30 12:55:35 +08:00
I Dewa Gede Bisma Mahendra d37f1dc0e5 Merge pull request #69 from Quantus-Network/beast/add-use-main-in-workflow
feat: make dockerfile use main an option
2026-06-24 17:43:08 +08:00
Beast 4d53fff54b feat: make dockerfile use main an option 2026-06-24 17:16:00 +08:00
I Dewa Gede Bisma Mahendra 18e8539fa3 Merge pull request #68 from Quantus-Network/beast/fix-docker-build
fix: docker build
2026-06-24 17:00:40 +08:00
Beast 4ab6914331 fix: docker build 2026-06-24 16:44:49 +08:00
illuzen 16fb76c5e8 bump version to v3.2.0 (#66) 2026-06-12 10:56:36 +08:00
illuzen 157aa6fb5c Address v12 issues (#65)
* don't accept zero difficulty jobs

* bound job queue

* synchronous emit_payload

* cleaner gpu_batch_size type

* remove build script

* distance_threshold -> difficulty

* enable pq-cert checking with miner

* clearer logs

* Revert "enable pq-cert checking with miner"

This reverts commit 7a7f5394352b60d8c946f547f77a057504b29c44.

* update versions of relevant deps, including rand
2026-06-12 10:48:01 +08:00
illuzen 5ce62b3e12 add gpu-throttle arg (#64)
* add gpu-throttle arg

* smarter sleep logic

* document cli args
2026-05-22 23:31:54 +08:00
Quantus Network CIandilluzen 8de756f456 bump version to v3.1.0 (#59)
Co-authored-by: illuzen <illuzen@users.noreply.github.com>
2026-04-22 13:51:40 +08:00
illuzen d7bf27854f GPU fixes (#58)
* improve logging

* don't let the queue spill, just drain it

* move cancellation out of the shader, since it doesn't work

* simplify batch size mechanism, improve logs

* fmt

* address review items
2026-04-22 13:42:01 +08:00
Nikolaus Heger f563d24241 Merge pull request #54 from Quantus-Network/release/v3.0.1
Release v3.0.1
2026-04-20 14:53:32 +08:00
Nikolaus Heger 334e852edd Merge pull request #53 from Quantus-Network/use-default-for-node-address
default for node addr
2026-04-20 14:48:06 +08:00
n13 67cce3f3c9 bump version to v3.0.1 2026-04-20 05:20:11 +00:00
Nikolaus Heger 9ffb5b12e2 default for node addr 2026-04-20 13:08:37 +08:00
Quantus Network CIandilluzen a60d95c825 bump version to v3.0.0 (#51)
Co-authored-by: illuzen <illuzen@users.noreply.github.com>
2026-04-02 14:22:41 +08:00
illuzenandNikolaus Heger 417a3efd86 Single hash, new poseidon (#50)
* single hash, new poseidon

* fix build

* new poseidon api

* fmt

* fix race condition with stale jobs being reported

* fmt

* fix deps

* format toml

remove stale comments

---------

Co-authored-by: Nikolaus Heger <nheger@gmail.com>
2026-04-02 14:06:54 +08:00
illuzen 443f0f9f89 QUIC miner (#49)
* implement quic

* simpler names

* simplify worker logic

* simplify gpu configuration logic

* miner initiates and picks random start nonce

* remove mining service

* clean up logs and remove unused engines

* clean up metrics

* metrics fix

* shared worker pool, cpu driven cancellation

just 10x the gpu thru-put, no more batch-size on gpu

* fmt

* non-local git

* taplo derp

* fix the benches

* handle stale results correctly in gpu

* more accurate gpu hashrate

* fmt

* remove unused

* handle duplicate solutions, cli controls gpu cancel frequency

* put plonky2 back

* demote noisy log
2026-02-02 16:38:21 +08:00
Nikolaus Heger 0a07bb2de5 Merge pull request #48 from Quantus-Network/release/v2.1.2
Release v2.1.2
2026-01-08 09:16:47 +08:00
n13 35df5a3554 bump version to v2.1.2 2026-01-08 01:14:06 +00:00
Nikolaus Heger bf8721f41d Merge pull request #46 from Quantus-Network/fix_hashrate_2
hash rate fix
2026-01-08 09:13:10 +08:00
NIK 590ce82440 fmt 2026-01-08 09:08:29 +08:00
Nikolaus Heger 689e5f9bfd hash rate fix 2026-01-06 15:17:00 +08:00
Nikolaus Heger 059fa8322c Merge pull request #45 from Quantus-Network/n13/fix_hashrate
fix hash rate across all threads and devices
2026-01-06 13:30:01 +08:00
Nikolaus Heger 8295d27d25 format 2026-01-06 12:35:36 +08:00
Nikolaus Heger e817c1a5e3 fix hash rate across all threads and devices 2026-01-05 19:27:40 +08:00
Nikolaus Heger a8ecc6f03c Merge pull request #44 from Quantus-Network/release/v2.1.1
Release v2.1.1
2026-01-05 18:42:55 +08:00
n13 91170c6aea bump version to v2.1.1 2026-01-05 10:35:00 +00:00
Nikolaus Heger 4131b60ebb Merge pull request #43 from Quantus-Network/fix_gpu_cancel_issue
Fix gpu cancel issue
2026-01-05 18:33:57 +08:00
Nikolaus Heger de3ffa9fb9 Merge branch 'fix_gpu_cancel_issue' of github.com:Quantus-Network/quantus-miner into fix_gpu_cancel_issue 2026-01-05 17:47:52 +08:00
Nikolaus Heger ad46f200c0 fmt 2026-01-05 17:47:47 +08:00
Nikolaus Heger d14ebdf65c fix tests 2026-01-05 17:47:38 +08:00
Nikolaus Heger 6834dd2dff format 2026-01-05 17:41:35 +08:00
Nikolaus Heger af5564796b dynamic batch size for GPU 2026-01-05 17:15:10 +08:00
Nikolaus Heger ffc5b8f83c GPU batch size automatically adjusts to target duration 2026-01-05 15:53:14 +08:00
Nikolaus Heger 3a694ecee1 Merge pull request #41 from Quantus-Network/release/v2.1.0
Release v2.1.0
2025-12-31 18:22:26 +08:00
n13 b9e080e20c bump version to v2.1.0 2025-12-31 10:19:01 +00:00
Nikolaus Heger 3705fa96e7 Merge pull request #40 from Quantus-Network/GPU_contention_fix
Gpu contention fix
2025-12-31 12:40:31 +08:00
Nikolaus Heger c3e7d70a75 fix readme 2025-12-27 11:57:12 +08:00
Nikolaus Heger 0b0731b869 fmt 2025-12-27 11:40:11 +08:00
Nikolaus Heger 3b5f759fbd refactored and unified gpu devices number logic
use all GPUs by default, handle the parameter, disallow more GPU devices than available
2025-12-27 11:40:03 +08:00
Nikolaus Heger 06933d5745 fix ci 2025-12-26 14:06:49 +08:00
Nikolaus Heger 96853cad23 restore serve command 2025-12-26 14:03:00 +08:00
Nikolaus Heger 010eff6e71 fix readme 2025-12-26 13:59:21 +08:00
Nikolaus Heger 4e7280cb0f fux ci 2025-12-26 13:58:27 +08:00
Nikolaus Heger c2773cb80d remove feature cpu 2025-12-26 13:50:48 +08:00
Nikolaus Heger b79a9368e0 fmt 2025-12-26 13:45:18 +08:00
Nikolaus Heger f0aa971326 remove gpu feature, rename to gpu-devices
rename gpu-workers flag to devices, also check user doesn't specify more than they have (degrades performance)
2025-12-26 13:44:59 +08:00
Nikolaus Heger 23c617bda9 remove some AI dust 2025-12-26 13:10:46 +08:00
Nikolaus Heger 2a36eeb3c3 Change workflows to have only GPU build 2025-12-26 13:02:43 +08:00
Nikolaus Heger a548f74f7d fmt 2025-12-26 12:47:42 +08:00
Nikolaus Heger 97ec1350de Fix for #39
Fix GPU worker crash, enable GPU default, and improve benchmark output
2025-12-26 12:46:57 +08:00
Nikolaus Heger ac87758996 Fix GPU contention, benchmarking 2025-12-26 10:53:00 +08:00
Quantus Network CIandczareko bdcd939bc9 bump version to v2.0.2 (#38)
Co-authored-by: czareko <czareko@users.noreply.github.com>
2025-12-23 06:30:16 +08:00
Cezary Olborski a51df35ca1 fix: Release - osx version (#37)
* fix: Release - OSX Version for Intel

* fix: OSX only for Intel
2025-12-23 06:25:18 +08:00
Quantus Network CIandczareko 41579e8223 bump version to v2.0.1 (#36)
Co-authored-by: czareko <czareko@users.noreply.github.com>
2025-12-23 06:00:01 +08:00
Cezary Olborski 516286836a fix: Release build script syntax (#35) 2025-12-23 05:56:08 +08:00
62 changed files with 8104 additions and 6231 deletions
-42
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/
+10 -16
View File
@@ -45,28 +45,17 @@ jobs:
name: 🛠️ Build & Test
needs: fast-checks
runs-on: ubuntu-latest
strategy:
matrix:
features: ["", "gpu"]
steps:
- uses: actions/checkout@v4
- name: setup rust
uses: actions-rust-lang/setup-rust-toolchain@v1
- name: compile (${{ matrix.features || 'cpu-only' }})
- name: compile (gpu)
run: |
if [ -n "${{ matrix.features }}" ]; then
cargo build --locked --workspace --features ${{ matrix.features }}
else
cargo build --locked --workspace
fi
cargo build --locked --workspace
timeout-minutes: 90
- name: test (${{ matrix.features || 'cpu-only' }})
- name: test (gpu)
run: |
if [ -n "${{ matrix.features }}" ]; then
cargo test --locked --workspace --features ${{ matrix.features }}
else
cargo test --locked --workspace
fi
cargo test --locked --workspace
timeout-minutes: 15
analysis:
@@ -95,8 +84,13 @@ jobs:
- name: setup rust
uses: actions-rust-lang/setup-rust-toolchain@v1
- name: build benchmark binary
run: cargo build -p miner-cli --release --features gpu
run: cargo build -p miner-cli --release
timeout-minutes: 60
- name: upload linux release binary
uses: actions/upload-artifact@v4
with:
name: quantus-miner-linux-x86_64
path: target/release/quantus-miner
- name: run cpu benchmark
run: ./target/release/quantus-miner benchmark --cpu-workers 2 --duration 5
timeout-minutes: 10
@@ -0,0 +1,13 @@
name: Dependency cooldown audit
on:
schedule:
- cron: '0 6 * * 1'
workflow_dispatch:
permissions:
contents: read
jobs:
audit:
uses: Quantus-Network/shared-workflows/.github/workflows/dependency-cooldown-audit.yml@v1
+14
View File
@@ -0,0 +1,14 @@
name: Dependency cooldown
on:
pull_request:
# labeled/unlabeled/edited are required so the gate re-runs when an
# emergency bypass label or reason is added.
types: [opened, synchronize, reopened, labeled, unlabeled, edited]
permissions:
contents: read
jobs:
dependency-cooldown:
uses: Quantus-Network/shared-workflows/.github/workflows/dependency-cooldown.yml@v1
-145
View File
@@ -1,145 +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: ""
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: Check if Dockerfile exists in tag
id: check_dockerfile
run: |
if [ ! -f Dockerfile ]; then
echo "::warning::Dockerfile not found in tag ${{ env.TARGET_VERSION_WITH_V }}, falling back to main branch Dockerfile"
echo "use_main=true" >> $GITHUB_OUTPUT
else
echo "Dockerfile found in tag"
echo "use_main=false" >> $GITHUB_OUTPUT
fi
- name: Checkout main branch for Dockerfile (if needed)
if: steps.check_dockerfile.outputs.use_main == '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: steps.check_dockerfile.outputs.use_main == '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@v5
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"
+2 -25
View File
@@ -71,45 +71,21 @@ jobs:
target: x86_64-unknown-linux-gnu
binary_name: quantus-miner
asset_name: quantus-miner-linux-x86_64
features: ""
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
binary_name: quantus-miner
asset_name: quantus-miner-linux-x86_64-gpu
features: "gpu"
# Windows builds
- os: windows-latest
target: x86_64-pc-windows-msvc
binary_name: quantus-miner.exe
asset_name: quantus-miner-windows-x86_64.exe
features: ""
- os: windows-latest
target: x86_64-pc-windows-msvc
binary_name: quantus-miner.exe
asset_name: quantus-miner-windows-x86_64-gpu.exe
features: "gpu"
# macOS builds (Intel)
- os: macos-13
- os: macos-15-intel
target: x86_64-apple-darwin
binary_name: quantus-miner
asset_name: quantus-miner-macos-x86_64
features: ""
- os: macos-13
target: x86_64-apple-darwin
binary_name: quantus-miner
asset_name: quantus-miner-macos-x86_64-gpu
features: "gpu"
# macOS builds (Apple Silicon)
- os: macos-latest
target: aarch64-apple-darwin
binary_name: quantus-miner
asset_name: quantus-miner-macos-aarch64
features: ""
- os: macos-latest
target: aarch64-apple-darwin
binary_name: quantus-miner
asset_name: quantus-miner-macos-aarch64-gpu
features: "gpu"
steps:
- uses: actions/checkout@v4
@@ -122,6 +98,7 @@ jobs:
target: ${{ matrix.target }}
- name: build binary
shell: bash
run: |
if [[ -n "${{ matrix.features }}" ]]; then
cargo build --release --locked --target ${{ matrix.target }} --features ${{ matrix.features }}
Generated
+650 -626
View File
File diff suppressed because it is too large Load Diff
+8 -8
View File
@@ -6,7 +6,10 @@ members = [
"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 +18,7 @@ resolver = "2"
edition = "2021"
authors = ["Quantus Network"]
description = "Quantus External Miner Workspace"
version = "2.0.0"
version = "4.0.2"
[workspace.dependencies]
anyhow = "1"
@@ -29,18 +32,15 @@ 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 = "1.0.1", default-features = false }
qpow-math = { git = "https://github.com/Quantus-Network/chain.git", package = "qpow-math", default-features = false }
quantus-miner-api = { version = "0.0.3", default-features = false }
rand = { version = "0.8.5", 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 = "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 }
thiserror = "1"
tokio = { version = "1.36", features = ["full"] }
warp = "0.3"
# pow-core now delegates to qpow-math for core functionality while providing optimized mining scaffolding.
# GPU backend dependencies are declared in individual engine crates (no optional deps in workspace)
[profile.release]
opt-level = 3
-55
View File
@@ -1,55 +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
COPY tests ./tests
# 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"]
-256
View File
@@ -1,256 +0,0 @@
# External Miner Protocol Specification
This document defines the JSON-based HTTP protocol for communication between the Resonance Network node and an external QPoW miner service.
## Overview
The node delegates the mining task (finding a valid nonce) to an external service. The node provides the necessary parameters (header hash, difficulty, nonce range) and the external miner searches for a valid nonce according to the QPoW rules defined in the `qpow-math` crate. The miner returns the result, including the winning nonce, when found.
## Data Types
See the `resonance-miner-api` crate for the canonical Rust definitions of these structures.
- `job_id`: String (UUID recommended) - Unique identifier for a specific mining task, generated by the node.
- `mining_hash`: String (64 hex chars, no 0x) - The header hash for which to find a nonce.
- `difficulty`: String (u64 as string) - The target difficulty for the mining job.
- `nonce_start`: String (128 hex chars, no 0x) - The starting nonce value (inclusive) for the search range.
- `nonce_end`: String (128 hex chars, no 0x) - The ending nonce value (inclusive) for the search range.
- `status`: Enum (`ApiResponseStatus`) - Indicates the state or result of an API call.
- `message`: String (optional) - Provides details for `Error` status responses.
- `nonce`: String (Hex, no 0x) - Represents the `U512` value of the current or winning nonce.
- `work`: String (128 hex chars, no 0x) - Represents the winning nonce as `[u8; 64]`. This is the value the node needs for verification.
- `hash_count`: Number (u64) - Number of nonces checked by the miner for the job.
- `elapsed_time`: Number (f64) - Time in seconds the miner spent on the job.
## Endpoints
### 1. Submit Mining Job
- **Endpoint:** `POST /mine`
- **Description:** The node requests the external miner to start searching for a valid nonce.
- **Request Body (`MiningRequest`):**
```json
{
"job_id": "...",
"mining_hash": "...",
"difficulty": "...",
"nonce_start": "...",
"nonce_end": "..."
}
```
- **Response Body (`MiningResponse`):**
- Success (200 OK):
```json
{
"status": "accepted",
"job_id": "..."
}
```
- Error (400 Bad Request - Invalid Input / 409 Conflict - Duplicate Job ID):
```json
{
"status": "error",
"job_id": "...",
"message": "..." // e.g., "Job already exists", "Invalid mining_hash (...)"
}
```
### 2. Get Job Result
- **Endpoint:** `GET /result/{job_id}`
- **Description:** The node polls the external miner to check the status and retrieve the result.
- **Path Parameter:**
- `job_id`: String (UUID) - The ID of the job to query.
- **Response Body (`MiningResult`):**
- Job Completed (200 OK):
```json
{
"status": "completed",
"job_id": "...",
"nonce": "...", // U512 hex value of winning nonce
"work": "...", // [u8; 64] hex value of winning nonce
"hash_count": ..., // u64
"elapsed_time": ... // f64 seconds
}
```
- Job Still Running (200 OK):
```json
{
"status": "running",
"job_id": "...",
"nonce": "...", // Current nonce being checked (U512 hex)
"work": null,
"hash_count": ..., // u64
"elapsed_time": ... // f64 seconds
}
```
- Job Failed (e.g., nonce range exhausted) (200 OK):
```json
{
"status": "failed",
"job_id": "...",
"nonce": "...", // Final nonce checked (U512 hex)
"work": null,
"hash_count": ..., // u64
"elapsed_time": ... // f64 seconds
}
```
- Job Not Found (404 Not Found):
```json
{
"status": "not_found",
"job_id": "...",
"nonce": null,
"work": null,
"hash_count": 0,
"elapsed_time": 0.0
}
```
### 3. Cancel Mining Job
- **Endpoint:** `POST /cancel/{job_id}`
- **Description:** The node requests the external miner to stop working on a specific job.
- **Path Parameter:**
- `job_id`: String (UUID) - The ID of the job to cancel.
- **Request Body:** (Empty)
- **Response Body (`MiningResponse`):
- Success (200 OK):
```json
{
"status": "cancelled",
"job_id": "..."
}
```
- Job Not Found (404 Not Found):
```json
{
"status": "not_found",
"job_id": "..."
}
```
## Notes
- All hex values (`mining_hash`, `nonce_start`, `nonce_end`, `nonce`, `work`) should be sent **without** the `0x` prefix.
- The miner must implement the validation logic defined in `qpow_math::is_valid_nonce`.
- The node relies primarily on the `work` field in the `MiningResult` (when status is `completed`) for constructing the `QPoWSeal`.
# External Miner Protocol Specification
This document defines the JSON-based HTTP protocol for communication between the node and an external QPoW miner.
## Overview
The node delegates the mining task to an external service. The node provides the necessary parameters (mining hash, difficulty, and a nonce range) and the external miner searches for a valid nonce within that range. The miner returns the nonce and the resulting work hash when a solution is found.
## Data Types
- `job_id`: String (UUID recommended) - Identifier for a specific mining task.
- `mining_hash`: String (Hex-encoded, 32-byte hash, H256) - The hash derived from the block header data that the miner needs to solve.
- `difficulty`: String (Decimal representation of u64) - The target difficulty for the block.
- `nonce_start`: String (Hex-encoded, 64-byte value, U512) - The starting nonce value (inclusive).
- `nonce_end`: String (Hex-encoded, 64-byte value, U512) - The ending nonce value (inclusive).
- `nonce`: String (Hex-encoded, 64-byte value, U512) - The solution found by the miner.
- `work`: String (Hex-encoded, 32-byte hash, H256) - The hash resulting from the combination of `mining_hash` and `nonce`, meeting the difficulty requirement.
- `status`: String Enum - Indicates the state or result of an API call.
## Endpoints
### 1. Start Mining Job
- **Endpoint:** `POST /mine`
- **Description:** The node requests the external miner to start searching for a valid nonce within the specified range for the given parameters.
- **Request Body (application/json):**
```json
{
"job_id": "...", // String (UUID), generated by the node
"mining_hash": "...", // Hex String (H256)
"difficulty": "...", // String (u64 decimal)
"nonce_start": "...", // Hex String (U512 hex)
"nonce_end": "..." // Hex String (U512 hex)
}
```
- **Response Body (application/json):**
- Success (200 OK):
```json
{
"status": "accepted",
"job_id": "..." // String (UUID), confirming the job ID received
}
```
- Error (e.g., 400 Bad Request, 500 Internal Server Error):
```json
{
"status": "rejected",
"reason": "..." // String (Description of error)
}
```
### 2. Get Job Result
- **Endpoint:** `GET /result/{job_id}`
- **Description:** The node polls the external miner to check the status and retrieve the result of a previously submitted job.
- **Path Parameter:**
- `job_id`: String (UUID) - The ID of the job to query.
- **Response Body (application/json):**
- Solution Found (200 OK):
```json
{
"status": "found",
"job_id": "...", // String (UUID)
"nonce": "...", // Hex String (U512 hex)
"work": "CAFEBABE01.." // Hex String (H256 hex)
}
```
- Still Working (200 OK):
```json
{
"status": "working",
"job_id": "..." // String (UUID)
}
```
- Job Stale/Cancelled (200 OK): Indicates the job is no longer valid (e.g., the node requested cancellation or submitted work for a newer block).
```json
{
"status": "stale",
"job_id": "..." // String (UUID)
}
```
- Job Not Found (404 Not Found):
```json
{
"status": "not_found",
"job_id": "..." // String (UUID)
}
```
### 3. Cancel Mining Job
- **Endpoint:** `POST /cancel/{job_id}`
- **Description:** The node requests the external miner to stop working on a specific job. This is typically used when the node receives a new block or its mining parameters change, making the old job obsolete.
- **Path Parameter:**
- `job_id`: String (UUID) - The ID of the job to cancel.
- **Request Body:** (Empty)
- **Response Body (application/json):**
- Success (200 OK):
```json
{
"status": "cancelled",
"job_id": "..." // String (UUID)
}
```
- Job Not Found (404 Not Found):
```json
{
"status": "not_found",
"job_id": "..." // String (UUID)
}
```
## Notes
- The external miner should iterate from `nonce_start` up to and including `nonce_end` when searching for a valid nonce.
- The miner should return the `nonce` and the calculated `work` hash when a solution is found.
- The node uses the returned `nonce` and `work` (along with the fetched `difficulty`) to construct the `QPoWSeal` and submit it.
- The external miner should not need to know anything about the runtime or the code; it only needs to perform the nonce search and return the results.
+76 -41
View File
@@ -9,35 +9,58 @@ High-performance external mining service for Quantus Network with support for CP
cargo build -p miner-cli --release
# With GPU support (recommended)
cargo build -p miner-cli --features gpu --release
cargo build -p miner-cli --release
```
The binary will be available at `target/release/quantus-miner`.
## Running
```bash
# CPU-only mining (default: auto-detected CPU cores)
./target/release/quantus-miner --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 (requires --features gpu build)
./target/release/quantus-miner --gpu-workers 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 --cpu-workers 4 --gpu-workers 1
# Custom port and metrics
./target/release/quantus-miner --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
| 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-workers <N>` | `MINER_GPU_WORKERS` | Number of GPU worker threads | 0 |
| `--port <PORT>` | `MINER_PORT` | HTTP API port | 9833 |
| `--metrics-port <PORT>` | `MINER_METRICS_PORT` | Prometheus metrics port | Disabled |
| `--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 |
| `--cpu-batch-size <N>` | `MINER_CPU_BATCH_SIZE` | CPU batch size in hashes | 10000 |
| `--gpu-throttle-ms <MS>` | `MINER_GPU_THROTTLE_MS` | Sleep duration (ms) between GPU batches | 0 |
| `--metrics-port <PORT>` | `MINER_METRICS_PORT` | Prometheus metrics port | 9900 |
## GPU Mining
@@ -51,7 +74,7 @@ GPU support uses WGPU for cross-platform acceleration:
**Build with GPU support:**
```bash
cargo build -p miner-cli --features gpu --release
cargo build -p miner-cli --release
```
**Platform requirements:**
@@ -67,48 +90,60 @@ cargo build -p miner-cli --features gpu --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 --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 --gpu-workers 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 \
--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 --cpu-workers 4 --gpu-workers 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 --cpu-workers 2 --gpu-workers 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 \
./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-workers 1 \
--port 9833 \
--gpu-devices 1 \
--metrics-port 9900
```
## API Endpoints
## Protocol
- `POST /mine`: Submit mining job
- `GET /result/{job_id}`: Get job status/result
- `POST /cancel/{job_id}`: Cancel job
The miner uses a QUIC-based protocol for communication with the node:
Full API specification: `api/openapi.yaml`
- **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**: `Ready` (miner→node), `NewJob` (node→miner), `JobResult` (miner→node)
## 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 --cpu-workers 4
```
For full protocol specification, see the node's `MINING.md`.
## Benchmarking
@@ -117,8 +152,8 @@ docker run -d -p 9833:9833 quantus-miner --cpu-workers 4
./target/release/quantus-miner benchmark --cpu-workers 8 --duration 30
# Benchmark GPU performance
./target/release/quantus-miner benchmark --gpu-workers 1 --duration 30
./target/release/quantus-miner benchmark --gpu-devices 1 --duration 30
# Benchmark hybrid performance
./target/release/quantus-miner benchmark --cpu-workers 4 --gpu-workers 1 --duration 30
./target/release/quantus-miner benchmark --cpu-workers 4 --gpu-devices 1 --duration 30
```
+80
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.
-242
View File
@@ -1,242 +0,0 @@
openapi: 3.0.0
info:
title: Quantus External Miner API
version: 0.1.0
description: API for managing QPoW mining jobs for the Resonance Network external miner service.
paths:
/mine:
post:
summary: Submit a new mining job
description: Accepts a mining job request and adds it to the queue if valid.
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/MiningRequest'
responses:
'200':
description: Job accepted successfully.
content:
application/json:
schema:
$ref: '#/components/schemas/MiningResponseAccepted'
'400':
description: Invalid request (e.g., missing fields, invalid format).
content:
application/json:
schema:
$ref: '#/components/schemas/MiningResponseError'
'409':
description: Conflict (e.g., duplicate job ID).
content:
application/json:
schema:
$ref: '#/components/schemas/MiningResponseError'
/result/{job_id}:
get:
summary: Get mining job status and result
description: Retrieves the current status and potentially the found nonce for a given job ID.
parameters:
- name: job_id
in: path
required: true
description: The ID of the mining job to retrieve.
schema:
type: string
format: uuid
responses:
'200':
description: Job status retrieved successfully (could be running, completed, or failed).
content:
application/json:
schema:
$ref: '#/components/schemas/MiningResult'
'404':
description: Job ID not found.
content:
application/json:
schema:
$ref: '#/components/schemas/MiningResultNotFound'
/cancel/{job_id}:
post:
summary: Cancel a mining job
description: Attempts to cancel an ongoing mining job.
parameters:
- name: job_id
in: path
required: true
description: The ID of the mining job to cancel.
schema:
type: string
format: uuid
responses:
'200':
description: Job cancelled successfully.
content:
application/json:
schema:
$ref: '#/components/schemas/MiningResponseCancelled'
'404':
description: Job ID not found.
content:
application/json:
schema:
$ref: '#/components/schemas/MiningResponseNotFound'
components:
schemas:
ApiResponseStatus:
type: string
description: Status code indicating the result of an API call or the state of a mining job.
enum:
- accepted
- running
- completed
- failed
- cancelled
- not_found
- error
example: running
MiningRequest:
type: object
required:
- job_id
- mining_hash
- difficulty
- nonce_start
- nonce_end
properties:
job_id:
type: string
format: uuid
description: Unique identifier for the mining job (UUID format recommended).
example: "123e4567-e89b-12d3-a456-426614174000"
mining_hash:
type: string
description: The header hash to mine on (hex encoded, 64 characters, no 0x).
example: "a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0"
pattern: '^[a-fA-F0-9]{64}$'
difficulty:
type: string
description: Target difficulty for the mining job (string representation of a u64).
example: "10000000000"
nonce_start:
type: string
description: Starting nonce value (hex encoded, 128 characters, no 0x).
example: "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
pattern: '^[a-fA-F0-9]{128}$'
nonce_end:
type: string
description: Ending nonce value (hex encoded, 128 characters, no 0x).
example: "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
pattern: '^[a-fA-F0-9]{128}$'
MiningResponseBase:
type: object
required:
- status
- job_id
properties:
status:
$ref: '#/components/schemas/ApiResponseStatus'
job_id:
type: string
format: uuid
description: The ID of the job associated with the response.
example: "123e4567-e89b-12d3-a456-426614174000"
MiningResponseAccepted:
allOf:
- $ref: '#/components/schemas/MiningResponseBase'
- type: object
properties:
status:
example: accepted
MiningResponseCancelled:
allOf:
- $ref: '#/components/schemas/MiningResponseBase'
- type: object
properties:
status:
example: cancelled
MiningResponseError:
allOf:
- $ref: '#/components/schemas/MiningResponseBase'
- type: object
properties:
status:
example: error
message:
type: string
description: Details about the error that occurred.
example: "Job already exists"
MiningResponseNotFound:
allOf:
- $ref: '#/components/schemas/MiningResponseBase'
- type: object
properties:
status:
example: not_found
MiningResult:
allOf:
- $ref: '#/components/schemas/MiningResponseBase'
- type: object
required:
- hash_count
- elapsed_time
properties:
nonce:
type: string
nullable: true
description: The current or winning nonce (U512 hex, no 0x).
example: "1a2b3c"
pattern: '^[a-fA-F0-9]+$'
work:
type: string
nullable: true
description: The winning nonce ([u8; 64] hex, 128 chars, no 0x). Only present if status is 'completed'.
example: "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001a2b3c"
pattern: '^[a-fA-F0-9]{128}$'
hash_count:
type: integer
format: uint64
description: Number of nonces checked so far for this job.
example: 123456
elapsed_time:
type: number
format: double
description: Time elapsed (in seconds) since the job started.
example: 15.732
MiningResultNotFound:
allOf:
- $ref: '#/components/schemas/MiningResponseBase'
- type: object
properties:
status:
example: not_found
nonce:
type: string
nullable: true
example: null
work:
type: string
nullable: true
example: null
hash_count:
type: integer
format: uint64
example: 0
elapsed_time:
type: number
format: double
example: 0.0
Executable
+7
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
-3
View File
@@ -20,9 +20,6 @@ simd-poseidon2 = ["pow-core/simd-poseidon2"]
[dependencies]
pow-core = { path = "../pow-core" }
primitive-types = { workspace = true }
log = { workspace = true }
thiserror = { workspace = true }
anyhow = { workspace = true }
[dev-dependencies]
hex = { workspace = true }
@@ -1,14 +1,15 @@
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use engine_cpu::{FastCpuEngine, MinerEngine, Range};
use engine_cpu::{AtomicBoolCancelCheck, FastCpuEngine, MinerEngine, Range};
use pow_core::{hash_from_nonce, JobContext};
use primitive_types::U512;
use rand::RngCore;
use std::sync::atomic::AtomicBool;
fn bench_cpu_fast_engine(c: &mut Criterion) {
// Create the engine
let engine = FastCpuEngine::new();
// Create the engine with batch size of 10000
let engine = FastCpuEngine::new(10_000);
let cancel_flag = AtomicBool::new(false);
let cancel_check = AtomicBoolCancelCheck(&cancel_flag);
let large_range = Range {
start: U512::from(0u64),
@@ -18,14 +19,14 @@ fn bench_cpu_fast_engine(c: &mut Criterion) {
c.bench_function("cpu_fast_large_range", |b| {
b.iter(|| {
let mut header = [0u8; 32];
rand::thread_rng().fill_bytes(&mut header);
rand::rng().fill_bytes(&mut header);
let difficulty = U512::from(10_000_000u64);
let ctx = JobContext::new(header, difficulty);
let result = engine.search_range(
black_box(&ctx),
black_box(large_range.clone()),
black_box(&cancel_flag),
black_box(&cancel_check),
);
black_box(result)
})
@@ -35,7 +36,7 @@ fn bench_cpu_fast_engine(c: &mut Criterion) {
fn bench_hash_from_nonce(c: &mut Criterion) {
// Create a test job context
let mut header = [0u8; 32];
rand::thread_rng().fill_bytes(&mut header);
rand::rng().fill_bytes(&mut header);
let difficulty = U512::from(1000u64);
let ctx = JobContext::new(header, difficulty);
+89 -272
View File
@@ -1,23 +1,14 @@
#![deny(rust_2018_idioms)]
#![forbid(unsafe_code)]
//! CPU mining engine scaffolding and trait definition.
//! CPU mining engine for Quantus.
//!
//! This crate defines a minimal `MinerEngine` trait so the service layer can
//! orchestrate mining without knowing about specific math/device details,
//! plus a baseline CPU implementation that uses the reference path in `pow-core`.
//!
//! The baseline engine performs a straightforward linear scan across an inclusive
//! nonce range, computing distance per nonce using the context (header-derived)
//! constants. It is intended as a correctness reference and may be replaced at
//! runtime by faster engines (e.g., incremental CPU path, CUDA/OpenCL engines).
//! This crate defines the `MinerEngine` trait for mining orchestration,
//! plus a fast CPU implementation using optimized paths from `pow-core`.
use core::cmp::Ordering;
use pow_core::{is_valid_nonce, JobContext};
use pow_core::JobContext;
use primitive_types::U512;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering as AtomicOrdering};
use std::time::Duration;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
/// An inclusive nonce range to search.
#[derive(Clone, Debug)]
@@ -44,13 +35,6 @@ pub enum FoundOrigin {
}
/// Status from an engine search.
///
/// For synchronous `search_range` calls, the final outcome will be one of:
/// - `Found`
/// - `Exhausted`
/// - `Cancelled`
///
/// `Running` is included for potential future async/streaming engines.
#[derive(Clone, Debug)]
pub enum EngineStatus {
Running {
@@ -67,117 +51,76 @@ pub enum EngineStatus {
Cancelled {
hash_count: u64,
},
/// GPU device lost or unresponsive. Worker should exit permanently.
DeviceLost {
hash_count: u64,
},
}
/// Cancellation checker passed to search_range.
/// Returns true if the search should be cancelled.
pub trait CancelCheck: Send + Sync {
fn is_cancelled(&self) -> bool;
}
/// Simple cancel check using an AtomicBool flag.
/// Useful for benchmarks and simple scenarios.
pub struct AtomicBoolCancelCheck<'a>(pub &'a AtomicBool);
impl CancelCheck for AtomicBoolCancelCheck<'_> {
fn is_cancelled(&self) -> bool {
self.0.load(Ordering::Relaxed)
}
}
/// Cancel check using job ID comparison.
/// When a new job starts, the current_job_id is incremented.
/// Workers compare their job_id against the current to detect cancellation.
pub struct JobIdCancelCheck<'a> {
pub current_job_id: &'a AtomicU64,
pub my_job_id: u64,
}
impl CancelCheck for JobIdCancelCheck<'_> {
fn is_cancelled(&self) -> bool {
self.current_job_id.load(Ordering::Relaxed) != self.my_job_id
}
}
/// Abstract mining engine interface.
///
/// The service layer depends only on this trait to manage jobs.
/// Different engines (baseline CPU, optimized CPU, CUDA, OpenCL) can implement
/// this trait and be selected at runtime via configuration.
/// Different engines (CPU, CUDA, OpenCL) can implement this trait.
pub trait MinerEngine: Send + Sync {
/// Human-readable engine name (for logs/metrics).
/// Human-readable engine name (for logs).
fn name(&self) -> &'static str;
/// Prepare a precomputed context for a job (header + difficulty).
fn prepare_context(&self, header_hash: [u8; 32], difficulty: U512) -> JobContext;
/// Search an inclusive nonce range with cancellation support.
///
/// Implementations should:
/// - Respect `cancel` promptly to minimize wasted work after a solution is found.
/// - Return `Found` with a `Candidate` on success.
/// - Return `Exhausted` if the range is fully searched without a solution.
/// - Return `Cancelled` if `cancel` was observed during the search.
fn search_range(&self, ctx: &JobContext, range: Range, cancel: &AtomicBool) -> EngineStatus;
fn search_range(
&self,
ctx: &JobContext,
range: Range,
cancel: &dyn CancelCheck,
) -> EngineStatus;
/// Enable downcasting to concrete engine types.
fn as_any(&self) -> &dyn std::any::Any;
}
/// Baseline CPU engine.
///
/// This implementation uses the reference path from `pow-core` for distance
/// computation and scans the range linearly, one nonce at a time.
#[derive(Default)]
pub struct BaselineCpuEngine;
impl BaselineCpuEngine {
pub fn new() -> Self {
Self
}
/// Fast CPU engine using optimized pow-core helpers.
pub struct FastCpuEngine {
/// How often to check for cancellation (in hashes)
batch_size: u64,
}
impl MinerEngine for BaselineCpuEngine {
fn name(&self) -> &'static str {
"cpu-baseline"
}
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: &AtomicBool) -> EngineStatus {
// Ensure start <= end (inclusive range). If not, treat as exhausted.
if range.start > range.end {
return EngineStatus::Exhausted { hash_count: 0 };
}
let mut current = range.start;
let mut hash_count: u64 = 0;
loop {
// Cancellation check
if cancel.load(AtomicOrdering::Relaxed) {
return EngineStatus::Cancelled { hash_count };
}
// Compute hash for this nonce using Bitcoin-style double Poseidon2
let (is_valid, hash) =
is_valid_nonce(ctx.header, current.to_big_endian(), ctx.difficulty);
hash_count = hash_count.saturating_add(1);
// Check if it meets difficulty target
if is_valid {
let work = current.to_big_endian();
let candidate = Candidate {
nonce: current,
work,
hash,
};
return EngineStatus::Found {
candidate,
hash_count,
origin: FoundOrigin::Cpu,
};
}
// Advance or finish
match current.cmp(&range.end) {
Ordering::Less => {
current = current.saturating_add(U512::one());
}
_ => {
// End of inclusive range reached
break EngineStatus::Exhausted { hash_count };
}
}
}
}
}
// Re-export commonly used items for convenience by consumers.
// Fast CPU engine using incremental pow-core helpers (init_worker_y0 + step_mul)
#[derive(Default)]
pub struct FastCpuEngine;
impl FastCpuEngine {
pub fn new() -> Self {
Self
pub fn new(batch_size: u64) -> Self {
Self {
batch_size: batch_size.max(1), // Ensure at least 1
}
}
}
@@ -194,24 +137,34 @@ impl MinerEngine for FastCpuEngine {
self
}
fn search_range(&self, ctx: &JobContext, range: Range, cancel: &AtomicBool) -> EngineStatus {
fn search_range(
&self,
ctx: &JobContext,
range: Range,
cancel: &dyn CancelCheck,
) -> EngineStatus {
use pow_core::{hash_from_nonce, is_valid_hash, step_nonce};
// Ensure start <= end (inclusive range). If not, treat as exhausted.
if range.start > range.end {
return EngineStatus::Exhausted { hash_count: 0 };
}
let mut current = range.start;
let mut hash_count: u64 = 0;
// Use decrementing counter to avoid modulo division in hot loop
// Initialize to 0 so we check cancellation immediately on first iteration
let mut until_check: u64 = 0;
loop {
// Cancellation check
if cancel.load(AtomicOrdering::Relaxed) {
return EngineStatus::Cancelled { hash_count };
// Check for cancellation every batch_size hashes
if until_check == 0 {
if cancel.is_cancelled() {
return EngineStatus::Cancelled { hash_count };
}
until_check = self.batch_size;
}
until_check -= 1;
// Compute hash using Bitcoin-style double Poseidon2
let hash = hash_from_nonce(ctx, current);
hash_count = hash_count.saturating_add(1);
@@ -232,7 +185,6 @@ impl MinerEngine for FastCpuEngine {
break;
}
// Advance to next nonce
current = step_nonce(current);
}
@@ -240,140 +192,8 @@ impl MinerEngine for FastCpuEngine {
}
}
#[derive(Default)]
pub struct ChainManipulatorEngine {
/// Base sleep per batch in nanoseconds; actual sleep increases linearly with job count.
pub base_delay_ns: u64,
/// Number of nonce attempts between sleeps.
pub step_batch: u64,
/// Optional cap for solved-block throttling (sleep index will not exceed this).
pub throttle_cap: Option<u64>,
/// Monotonically increasing solved-block counter used to scale throttling.
/// Public so the service can initialize from CLI (pick up where we left off).
pub job_index: AtomicU64,
}
impl ChainManipulatorEngine {
pub fn new() -> Self {
// Start fast: first block has no throttle (job_index = 0 -> 0ns sleep),
// then 0.5ms per batch at block 1, 1.0ms at block 2, etc.
Self {
base_delay_ns: 500_000, // 0.5 ms
step_batch: 10_000, // sleep every 10k nonce checks
throttle_cap: None, // unlimited by default
job_index: AtomicU64::new(0),
}
}
}
impl MinerEngine for ChainManipulatorEngine {
fn name(&self) -> &'static str {
"cpu-chain-manipulator"
}
fn prepare_context(&self, header_hash: [u8; 32], difficulty: U512) -> JobContext {
// Per-block throttling: do NOT increment here. We increment on Found (i.e., when a block is solved).
let ctx = JobContext::new(header_hash, difficulty);
// Debug: log current throttle state at job start
log::debug!(
target: "miner",
"manipulator throttle start: solved_blocks={}, sleep_ns_per_batch={}, step_batch={}",
self.job_index.load(AtomicOrdering::Relaxed),
self.base_delay_ns.saturating_mul(self.job_index.load(AtomicOrdering::Relaxed)),
self.step_batch
);
ctx
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn search_range(&self, ctx: &JobContext, range: Range, cancel: &AtomicBool) -> EngineStatus {
use pow_core::{hash_from_nonce, is_valid_hash, step_nonce};
if range.start > range.end {
return EngineStatus::Exhausted { hash_count: 0 };
}
// Bitcoin-style hashing path
let mut current = range.start;
let mut hash_count: u64 = 0;
let mut batch_counter: u64 = 0;
// Per-block throttling: derived from blocks solved so far (apply cap if configured).
let mut solved_blocks = self.job_index.load(AtomicOrdering::Relaxed);
if let Some(cap) = self.throttle_cap {
if solved_blocks > cap {
solved_blocks = cap;
}
}
let sleep_ns = self.base_delay_ns.saturating_mul(solved_blocks);
let do_sleep = sleep_ns > 0;
loop {
if cancel.load(AtomicOrdering::Relaxed) {
return EngineStatus::Cancelled { hash_count };
}
// Compute hash using Bitcoin-style double Poseidon2
let hash = hash_from_nonce(ctx, current);
hash_count = hash_count.saturating_add(1);
batch_counter = batch_counter.saturating_add(1);
// Optional detailed debug of throttle progression within a job
#[allow(unused_variables)]
let _dbg_batch = batch_counter;
if is_valid_hash(ctx, hash) {
let work = current.to_big_endian();
// Increment solved-block counter so the NEXT block throttles more.
let _new_idx = self.job_index.fetch_add(1, AtomicOrdering::Relaxed) + 1;
{
let capped = if let Some(cap) = self.throttle_cap {
std::cmp::min(_new_idx, cap)
} else {
_new_idx
};
log::debug!(
target: "miner",
"manipulator throttle increment: solved_blocks={} (next sleep_ns_per_batch={}, cap={:?})",
_new_idx,
self.base_delay_ns.saturating_mul(capped),
self.throttle_cap
);
}
return EngineStatus::Found {
candidate: Candidate {
nonce: current,
work,
hash,
},
hash_count,
origin: FoundOrigin::Cpu,
};
}
// Throttle after each batch to artificially slow down based on blocks solved so far.
if do_sleep && batch_counter >= self.step_batch {
std::thread::sleep(Duration::from_nanos(sleep_ns));
batch_counter = 0;
}
// Advance
if current < range.end {
current = step_nonce(current);
} else {
break EngineStatus::Exhausted { hash_count };
}
}
}
}
pub use {
BaselineCpuEngine as DefaultEngine, Candidate as EngineCandidate,
ChainManipulatorEngine as ChainEngine, FastCpuEngine as FastEngine, Range as EngineRange,
};
// Re-exports for convenience
pub use {Candidate as EngineCandidate, Range as EngineRange};
#[cfg(test)]
mod tests {
@@ -381,33 +201,26 @@ mod tests {
use primitive_types::U512;
use std::sync::atomic::AtomicBool;
fn make_ctx() -> JobContext {
let header = [1u8; 32];
let difficulty = U512::from(1u64); // easy difficulty for "found" parity test
JobContext::new(header, difficulty)
}
#[test]
fn engine_returns_exhausted_when_no_solution_in_range() {
// Use a very hard difficulty to make solutions effectively impossible in a tiny range.
let header = [2u8; 32];
let difficulty = U512::MAX;
let difficulty = U512::MAX; // impossible difficulty
let ctx = JobContext::new(header, difficulty);
let range = Range {
start: U512::from(1u64),
end: U512::from(1000u64), // small range; probability of accidental match is negligible
end: U512::from(1000u64),
};
let cancel = AtomicBool::new(false);
let baseline = BaselineCpuEngine::new();
let cancel_check = AtomicBoolCancelCheck(&cancel);
let engine = FastCpuEngine::new(1000); // check every 1000 hashes
let status = baseline.search_range(&ctx, range.clone(), &cancel);
let status = engine.search_range(&ctx, range.clone(), &cancel_check);
match status {
EngineStatus::Exhausted { hash_count } => {
// Inclusive range length = end - start + 1
let expected = (range.end - range.start + U512::one()).as_u64();
assert_eq!(hash_count, expected, "hash_count should equal range length");
assert_eq!(hash_count, expected);
}
other => panic!("expected Exhausted, got {other:?}"),
}
@@ -415,19 +228,23 @@ mod tests {
#[test]
fn engine_respects_immediate_cancellation() {
let ctx = make_ctx();
let header = [1u8; 32];
let difficulty = U512::from(1u64);
let ctx = JobContext::new(header, difficulty);
let range = Range {
start: U512::from(0u64),
end: U512::from(1_000_000u64),
};
let cancel = AtomicBool::new(true); // cancelled before starting
let baseline = BaselineCpuEngine::new();
let status = baseline.search_range(&ctx, range, &cancel);
let cancel = AtomicBool::new(true); // pre-cancelled
let cancel_check = AtomicBoolCancelCheck(&cancel);
let engine = FastCpuEngine::new(1); // check every hash for immediate cancellation
let status = engine.search_range(&ctx, range, &cancel_check);
match status {
EngineStatus::Cancelled { hash_count } => {
// Cancellation was pre-set; allow zero or near-zero work depending on timing.
assert_eq!(hash_count, 0, "expected no work when cancelled immediately");
assert_eq!(hash_count, 0);
}
other => panic!("expected Cancelled, got {other:?}"),
}
+11 -13
View File
@@ -11,38 +11,36 @@ description = "GPU mining engine implementation for the Quantus External Miner"
[features]
# Default to the baseline/reference implementation; enable others as needed.
default = ["baseline", "metrics"]
default = ["baseline"]
# Map engine features (pow-core dependency removed for now)
baseline = []
simd-poseidon2 = []
metrics = ["dep:metrics"]
[dependencies]
engine-cpu = { path = "../engine-cpu" }
pow-core = { path = "../pow-core" }
metrics = { path = "../metrics", optional = true }
primitive-types = { workspace = true }
log = { workspace = true }
thiserror = { workspace = true }
anyhow = { workspace = true }
qp-poseidon-core = { version = "1.0.2", features = ["p2"] }
qp-poseidon-constants = { version = "1.0.2" }
qp-plonky2 = { version = "1.1.1" }
qp-poseidon-core = { workspace = true }
qp-poseidon-constants = "1.1"
qp-plonky2-field = { version = "1.1.1" }
plonky2 = { package = "qp-plonky2", version = "1.1.3" }
wgpu = { version = "27.0.1" } # GPU compute library
futures = "0.3" # For async executor
bytemuck = "1.16" # For buffer mapping
wgpu = { version = "27.0.1" } # GPU compute library
tokio = { workspace = true, features = ["rt", "rt-multi-thread", "time"] } # Async runtime with timeout support
futures = "0.3" # For async oneshot channels in test modules
bytemuck = "1.16" # For buffer mapping
rand = { workspace = true, features = ["std", "std_rng"] }
rand_chacha = "0.3" # For deterministic random test generation
rand_chacha = "0.9" # For deterministic random test generation
regex = "1" # For GPU name pattern matching
[dev-dependencies]
hex = { workspace = true }
criterion = "0.5"
rand = { workspace = true, features = ["std", "std_rng"] }
env_logger.workspace = true
tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "time"] } # Add macros for #[tokio::main]
[[bench]]
name = "gpu_engine_bench"
+58 -38
View File
@@ -1,15 +1,24 @@
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use engine_cpu::{FastCpuEngine, MinerEngine, Range};
use engine_cpu::{AtomicBoolCancelCheck, FastCpuEngine, MinerEngine, Range};
use engine_gpu::GpuEngine;
use pow_core::JobContext;
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();
let gpu_engine = GpuEngine::new();
let cpu_engine = FastCpuEngine::new(10_000);
let gpu_engine = GpuEngine::try_new(10_000_000, 0, false).expect("Failed to init GPU");
let cancel_flag = AtomicBool::new(false);
let cancel_check = AtomicBoolCancelCheck(&cancel_flag);
// Small range: 10K nonces - reasonable for benchmarking
let small_range = Range {
@@ -24,14 +33,14 @@ fn bench_cpu_vs_gpu_small(c: &mut Criterion) {
group.bench_function("cpu", |b| {
b.iter(|| {
let mut header = [0u8; 32];
rand::thread_rng().fill_bytes(&mut header);
rand::rng().fill_bytes(&mut header);
let difficulty = U512::from(u64::MAX); // High difficulty - no solutions expected
let ctx = JobContext::new(header, difficulty);
let result = cpu_engine.search_range(
black_box(&ctx),
black_box(small_range.clone()),
black_box(&cancel_flag),
black_box(&cancel_check),
);
black_box(result)
})
@@ -40,26 +49,28 @@ fn bench_cpu_vs_gpu_small(c: &mut Criterion) {
group.bench_function("gpu", |b| {
b.iter(|| {
let mut header = [0u8; 32];
rand::thread_rng().fill_bytes(&mut header);
rand::rng().fill_bytes(&mut header);
let difficulty = U512::from(u64::MAX); // High difficulty - no solutions expected
let ctx = JobContext::new(header, difficulty);
let result = gpu_engine.search_range(
black_box(&ctx),
black_box(small_range.clone()),
black_box(&cancel_flag),
black_box(&cancel_check),
);
black_box(result)
})
});
group.finish();
teardown_gpu(gpu_engine);
}
fn bench_cpu_vs_gpu_medium(c: &mut Criterion) {
let cpu_engine = FastCpuEngine::new();
let gpu_engine = GpuEngine::new();
let cpu_engine = FastCpuEngine::new(10_000);
let gpu_engine = GpuEngine::try_new(10_000_000, 0, false).expect("Failed to init GPU");
let cancel_flag = AtomicBool::new(false);
let cancel_check = AtomicBoolCancelCheck(&cancel_flag);
// Medium range: 100K nonces
let medium_range = Range {
@@ -74,14 +85,14 @@ fn bench_cpu_vs_gpu_medium(c: &mut Criterion) {
group.bench_function("cpu", |b| {
b.iter(|| {
let mut header = [0u8; 32];
rand::thread_rng().fill_bytes(&mut header);
rand::rng().fill_bytes(&mut header);
let difficulty = U512::from(u64::MAX); // High difficulty - no solutions expected
let ctx = JobContext::new(header, difficulty);
let result = cpu_engine.search_range(
black_box(&ctx),
black_box(medium_range.clone()),
black_box(&cancel_flag),
black_box(&cancel_check),
);
black_box(result)
})
@@ -90,26 +101,28 @@ fn bench_cpu_vs_gpu_medium(c: &mut Criterion) {
group.bench_function("gpu", |b| {
b.iter(|| {
let mut header = [0u8; 32];
rand::thread_rng().fill_bytes(&mut header);
rand::rng().fill_bytes(&mut header);
let difficulty = U512::from(u64::MAX); // High difficulty - no solutions expected
let ctx = JobContext::new(header, difficulty);
let result = gpu_engine.search_range(
black_box(&ctx),
black_box(medium_range.clone()),
black_box(&cancel_flag),
black_box(&cancel_check),
);
black_box(result)
})
});
group.finish();
teardown_gpu(gpu_engine);
}
fn bench_cpu_vs_gpu_large(c: &mut Criterion) {
let cpu_engine = FastCpuEngine::new();
let gpu_engine = GpuEngine::new();
let cpu_engine = FastCpuEngine::new(10_000);
let gpu_engine = GpuEngine::try_new(10_000_000, 0, false).expect("Failed to init GPU");
let cancel_flag = AtomicBool::new(false);
let cancel_check = AtomicBoolCancelCheck(&cancel_flag);
// Large range: 1M nonces - where GPU should really shine
let large_range = Range {
@@ -124,14 +137,14 @@ fn bench_cpu_vs_gpu_large(c: &mut Criterion) {
group.bench_function("cpu", |b| {
b.iter(|| {
let mut header = [0u8; 32];
rand::thread_rng().fill_bytes(&mut header);
rand::rng().fill_bytes(&mut header);
let difficulty = U512::from(u64::MAX); // High difficulty - no solutions expected
let ctx = JobContext::new(header, difficulty);
let result = cpu_engine.search_range(
black_box(&ctx),
black_box(large_range.clone()),
black_box(&cancel_flag),
black_box(&cancel_check),
);
black_box(result)
})
@@ -140,26 +153,28 @@ fn bench_cpu_vs_gpu_large(c: &mut Criterion) {
group.bench_function("gpu", |b| {
b.iter(|| {
let mut header = [0u8; 32];
rand::thread_rng().fill_bytes(&mut header);
rand::rng().fill_bytes(&mut header);
let difficulty = U512::from(u64::MAX); // High difficulty - no solutions expected
let ctx = JobContext::new(header, difficulty);
let result = gpu_engine.search_range(
black_box(&ctx),
black_box(large_range.clone()),
black_box(&cancel_flag),
black_box(&cancel_check),
);
black_box(result)
})
});
group.finish();
teardown_gpu(gpu_engine);
}
fn bench_solution_finding(c: &mut Criterion) {
let cpu_engine = FastCpuEngine::new();
let gpu_engine = GpuEngine::new();
let cpu_engine = FastCpuEngine::new(10_000);
let gpu_engine = GpuEngine::try_new(10_000_000, 0, false).expect("Failed to init GPU");
let cancel_flag = AtomicBool::new(false);
let cancel_check = AtomicBoolCancelCheck(&cancel_flag);
// Range where we expect to find solutions quickly
let solution_range = Range {
@@ -174,14 +189,14 @@ fn bench_solution_finding(c: &mut Criterion) {
group.bench_function("cpu_find_solution", |b| {
b.iter(|| {
let mut header = [0u8; 32];
rand::thread_rng().fill_bytes(&mut header);
rand::rng().fill_bytes(&mut header);
let difficulty = U512::from(10_000u64); // Easy difficulty - should find solution
let ctx = JobContext::new(header, difficulty);
let result = cpu_engine.search_range(
black_box(&ctx),
black_box(solution_range.clone()),
black_box(&cancel_flag),
black_box(&cancel_check),
);
black_box(result)
})
@@ -190,26 +205,28 @@ fn bench_solution_finding(c: &mut Criterion) {
group.bench_function("gpu_find_solution", |b| {
b.iter(|| {
let mut header = [0u8; 32];
rand::thread_rng().fill_bytes(&mut header);
rand::rng().fill_bytes(&mut header);
let difficulty = U512::from(10_000u64); // Easy difficulty - should find solution
let ctx = JobContext::new(header, difficulty);
let result = gpu_engine.search_range(
black_box(&ctx),
black_box(solution_range.clone()),
black_box(&cancel_flag),
black_box(&cancel_check),
);
black_box(result)
})
});
group.finish();
teardown_gpu(gpu_engine);
}
fn bench_throughput_per_second(c: &mut Criterion) {
let cpu_engine = FastCpuEngine::new();
let gpu_engine = GpuEngine::new();
let cpu_engine = FastCpuEngine::new(10_000);
let gpu_engine = GpuEngine::try_new(10_000_000, 0, false).expect("Failed to init GPU");
let cancel_flag = AtomicBool::new(false);
let cancel_check = AtomicBoolCancelCheck(&cancel_flag);
// Fixed time benchmark - see how many hashes we can do in 1 second
let throughput_range = Range {
@@ -224,14 +241,14 @@ fn bench_throughput_per_second(c: &mut Criterion) {
group.bench_function("cpu_throughput", |b| {
b.iter(|| {
let mut header = [0u8; 32];
rand::thread_rng().fill_bytes(&mut header);
rand::rng().fill_bytes(&mut header);
let difficulty = U512::from(u64::MAX); // High difficulty - no solutions expected
let ctx = JobContext::new(header, difficulty);
let result = cpu_engine.search_range(
black_box(&ctx),
black_box(throughput_range.clone()),
black_box(&cancel_flag),
black_box(&cancel_check),
);
black_box(result)
})
@@ -240,25 +257,27 @@ fn bench_throughput_per_second(c: &mut Criterion) {
group.bench_function("gpu_throughput", |b| {
b.iter(|| {
let mut header = [0u8; 32];
rand::thread_rng().fill_bytes(&mut header);
rand::rng().fill_bytes(&mut header);
let difficulty = U512::from(u64::MAX); // High difficulty - no solutions expected
let ctx = JobContext::new(header, difficulty);
let result = gpu_engine.search_range(
black_box(&ctx),
black_box(throughput_range.clone()),
black_box(&cancel_flag),
black_box(&cancel_check),
);
black_box(result)
})
});
group.finish();
teardown_gpu(gpu_engine);
}
fn bench_gpu_batch_efficiency(c: &mut Criterion) {
let gpu_engine = GpuEngine::new();
let gpu_engine = GpuEngine::try_new(10_000_000, 0, false).expect("Failed to init GPU");
let cancel_flag = AtomicBool::new(false);
let cancel_check = AtomicBoolCancelCheck(&cancel_flag);
let mut group = c.benchmark_group("gpu_batch_sizes");
group.sample_size(10);
@@ -283,14 +302,14 @@ fn bench_gpu_batch_efficiency(c: &mut Criterion) {
group.bench_function("gpu_1k_batch", |b| {
b.iter(|| {
let mut header = [0u8; 32];
rand::thread_rng().fill_bytes(&mut header);
rand::rng().fill_bytes(&mut header);
let difficulty = U512::from(u64::MAX);
let ctx = JobContext::new(header, difficulty);
let result = gpu_engine.search_range(
black_box(&ctx),
black_box(small_batch.clone()),
black_box(&cancel_flag),
black_box(&cancel_check),
);
black_box(result)
})
@@ -299,14 +318,14 @@ fn bench_gpu_batch_efficiency(c: &mut Criterion) {
group.bench_function("gpu_50k_batch", |b| {
b.iter(|| {
let mut header = [0u8; 32];
rand::thread_rng().fill_bytes(&mut header);
rand::rng().fill_bytes(&mut header);
let difficulty = U512::from(u64::MAX);
let ctx = JobContext::new(header, difficulty);
let result = gpu_engine.search_range(
black_box(&ctx),
black_box(medium_batch.clone()),
black_box(&cancel_flag),
black_box(&cancel_check),
);
black_box(result)
})
@@ -315,20 +334,21 @@ fn bench_gpu_batch_efficiency(c: &mut Criterion) {
group.bench_function("gpu_500k_batch", |b| {
b.iter(|| {
let mut header = [0u8; 32];
rand::thread_rng().fill_bytes(&mut header);
rand::rng().fill_bytes(&mut header);
let difficulty = U512::from(u64::MAX);
let ctx = JobContext::new(header, difficulty);
let result = gpu_engine.search_range(
black_box(&ctx),
black_box(large_batch.clone()),
black_box(&cancel_flag),
black_box(&cancel_check),
);
black_box(result)
})
});
group.finish();
teardown_gpu(gpu_engine);
}
criterion_group!(
@@ -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");
}
@@ -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
);
}
}
+52
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
);
}
@@ -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");
}
}
+8 -4
View File
@@ -1,4 +1,4 @@
use engine_cpu::{BaselineCpuEngine, EngineStatus, MinerEngine, Range};
use engine_cpu::{AtomicBoolCancelCheck, EngineStatus, FastCpuEngine, MinerEngine, Range};
use engine_gpu::GpuEngine;
use primitive_types::U512;
use std::sync::atomic::AtomicBool;
@@ -16,16 +16,17 @@ fn main() {
// Use a fixed header and easy difficulty (1) so any nonce is valid
let header = [1u8; 32];
let difficulty = U512::from(u64::MAX); // High difficulty - no solutions expected
let cpu_engine = BaselineCpuEngine::new();
let cpu_engine = FastCpuEngine::new(10_000);
let ctx = cpu_engine.prepare_context(header, difficulty);
log::info!("Context prepared. Difficulty: {}", difficulty);
let cancel = AtomicBool::new(false);
let cancel_check = AtomicBoolCancelCheck(&cancel);
// 3. Verify with GPU engine
log::info!("Initializing GPU engine...");
let gpu_engine = GpuEngine::new();
let gpu_engine = GpuEngine::try_new(10_000_000, 0, false).expect("Failed to init GPU");
// Search a small range around the valid nonce
let gpu_range = Range {
@@ -39,7 +40,7 @@ fn main() {
gpu_range.end
);
let start = std::time::Instant::now();
let gpu_result = gpu_engine.search_range(&ctx, gpu_range, &cancel);
let gpu_result = gpu_engine.search_range(&ctx, gpu_range, &cancel_check);
let elapsed = start.elapsed();
log::info!("GPU search took {:?}", elapsed);
@@ -55,6 +56,9 @@ fn main() {
EngineStatus::Cancelled { .. } => {
log::error!("FAILURE: GPU search cancelled!");
}
EngineStatus::DeviceLost { .. } => {
log::error!("FAILURE: GPU device lost!");
}
EngineStatus::Running { .. } => {
log::error!("FAILURE: GPU returned Running status!");
}
+31 -21
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, work_per_batch, threads_per_workgroup]
let dispatch_config_data: [u32; 4] = [256, 1, 1, 256];
// 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,11 +99,9 @@ pub async fn test_end_to_end_mining(
bytemuck::cast_slice(&dispatch_config_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 {
@@ -123,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,
@@ -193,18 +194,27 @@ pub async fn test_end_to_end_mining(
let found_nonce = U512::from_little_endian(&nonce_bytes);
println!("GPU Nonce: {}", found_nonce);
assert_eq!(found_nonce, nonce_val, "Nonce mismatch!");
// Parse hash
// Parse hash from GPU result
let mut hash_bytes = [0u8; 64];
for i in 0..16 {
let bytes = result_u32s[17 + i].to_le_bytes();
hash_bytes[i * 4..(i + 1) * 4].copy_from_slice(&bytes);
}
let found_hash = U512::from_little_endian(&hash_bytes);
println!("GPU Hash: {:x}", found_hash);
let gpu_hash = U512::from_little_endian(&hash_bytes);
println!("GPU Hash: {:x}", gpu_hash);
// Verify: compute hash on CPU for the found nonce and compare
let cpu_verified_hash = hash_from_nonce(&ctx, found_nonce);
println!("CPU verified hash: {:x}", cpu_verified_hash);
assert_eq!(
gpu_hash, cpu_verified_hash,
"GPU hash doesn't match CPU verification for the found nonce!"
);
// Verify hash is below target (should always pass with difficulty 1)
assert!(gpu_hash <= ctx.target, "Hash is not below target!");
assert_eq!(found_hash, expected_hash, "Hash mismatch!");
println!("✅ End-to-End Test Passed!");
} else {
println!("❌ GPU did not find solution (should have passed with MAX target)");
+748
View File
@@ -0,0 +1,748 @@
//! GPU tier detection using regex patterns
//!
//! This module provides a table-driven approach to GPU detection,
//! using regex patterns with word boundaries to avoid substring matching issues.
use regex::Regex;
use std::sync::LazyLock;
/// GPU tier configuration
struct GpuTier {
/// Regex pattern to match GPU name (case-insensitive)
pattern: &'static str,
/// Human-readable tier name
name: &'static str,
/// Divisor for max_workgroups (higher = more conservative)
workgroup_divisor: u32,
/// Minimum workgroups to use
min_workgroups: u32,
}
/// Compiled GPU tier with regex
struct CompiledGpuTier {
regex: Regex,
name: &'static str,
workgroup_divisor: u32,
min_workgroups: u32,
}
impl CompiledGpuTier {
fn from_tier(tier: &GpuTier) -> Self {
Self {
regex: Regex::new(&format!("(?i){}", tier.pattern)).expect("Invalid GPU tier regex"),
name: tier.name,
workgroup_divisor: tier.workgroup_divisor,
min_workgroups: tier.min_workgroups,
}
}
}
// GPU tiers are checked in order - first match wins
// Use word boundaries (\b) to avoid substring issues like "550" matching "5500"
const NVIDIA_TIERS: &[GpuTier] = &[
// Blackwell (RTX 50 series)
GpuTier {
pattern: r"\b50[89]0\b",
name: "NVIDIA RTX 50 Flagship (Blackwell)",
workgroup_divisor: 6,
min_workgroups: 5120,
},
GpuTier {
pattern: r"\b50[67]0\b|rtx 50",
name: "NVIDIA RTX 50 (Blackwell)",
workgroup_divisor: 7,
min_workgroups: 4608,
},
// Ada Lovelace (RTX 40 series)
GpuTier {
pattern: r"\b40[89]0\b",
name: "NVIDIA RTX 40 Flagship (Ada)",
workgroup_divisor: 8,
min_workgroups: 4096,
},
GpuTier {
pattern: r"\b40[67]0\b|rtx 40",
name: "NVIDIA RTX 40 (Ada)",
workgroup_divisor: 10,
min_workgroups: 3072,
},
// Ampere/Turing (RTX 30/20 series)
GpuTier {
pattern: r"\b30[5-9]0\b|\b20[6-8]0\b|rtx 30|rtx 20",
name: "NVIDIA RTX 30/20 (Ampere/Turing)",
workgroup_divisor: 12,
min_workgroups: 2048,
},
// Turing/Pascal (GTX 16/10 series)
GpuTier {
pattern: r"\b16[56]0\b|\b10[3-8]0\b|gtx 16|gtx 10",
name: "NVIDIA GTX 16/10 (Turing/Pascal)",
workgroup_divisor: 16,
min_workgroups: 1024,
},
// Maxwell (GTX 900 series)
GpuTier {
pattern: r"\b9[5-8]0\b|gtx 9",
name: "NVIDIA GTX 900 (Maxwell)",
workgroup_divisor: 18,
min_workgroups: 768,
},
// Kepler/Maxwell (GTX 700 series)
GpuTier {
pattern: r"\b7[5-8]0\b|gtx 7",
name: "NVIDIA GTX 700 (Kepler/Maxwell)",
workgroup_divisor: 20,
min_workgroups: 512,
},
// Legacy GTX
GpuTier {
pattern: r"gtx [456]",
name: "NVIDIA GTX Legacy (Fermi/Kepler)",
workgroup_divisor: 24,
min_workgroups: 384,
},
GpuTier {
pattern: r"\bgtx\b",
name: "NVIDIA GTX (Unknown)",
workgroup_divisor: 20,
min_workgroups: 512,
},
// Mobile
GpuTier {
pattern: r"\bmx[1-5]\d0|geforce mx",
name: "NVIDIA MX (Mobile)",
workgroup_divisor: 24,
min_workgroups: 384,
},
GpuTier {
pattern: r"geforce gt\b|\bgt \d{3}\b",
name: "NVIDIA GT (Entry-Level)",
workgroup_divisor: 28,
min_workgroups: 256,
},
// Professional
GpuTier {
pattern: r"quadro|rtx a\d|tesla|\ba100\b|\bh100\b|\bl4\b",
name: "NVIDIA Quadro/Professional",
workgroup_divisor: 10,
min_workgroups: 2560,
},
];
const AMD_TIERS: &[GpuTier] = &[
// RDNA 4 (RX 9000 series)
GpuTier {
pattern: r"rx 9|\b90[78]0\b",
name: "AMD RX 9000 (RDNA 4)",
workgroup_divisor: 8,
min_workgroups: 4096,
},
// RDNA 3 Discrete
GpuTier {
pattern: r"\b7900\b",
name: "AMD RX 7900 (RDNA 3 Flagship)",
workgroup_divisor: 9,
min_workgroups: 3584,
},
GpuTier {
pattern: r"rx 7|\b7[6-8]00\b",
name: "AMD RX 7000 (RDNA 3)",
workgroup_divisor: 10,
min_workgroups: 3072,
},
// RDNA 3 APUs - check before discrete to avoid substring match
GpuTier {
pattern: r"\b780m\b|radeon 780m",
name: "AMD Radeon 780M (RDNA 3 APU)",
workgroup_divisor: 12,
min_workgroups: 2048,
},
GpuTier {
pattern: r"\b7[46]0m\b|radeon 7[46]0m",
name: "AMD Radeon 7x0M (RDNA 3 APU)",
workgroup_divisor: 16,
min_workgroups: 1024,
},
// RDNA 2 Discrete
GpuTier {
pattern: r"\b6[89][05]0\b",
name: "AMD RX 6900/6800 (RDNA 2 Flagship)",
workgroup_divisor: 12,
min_workgroups: 2560,
},
GpuTier {
pattern: r"\b6[67][05]0\b",
name: "AMD RX 6700/6600 (RDNA 2)",
workgroup_divisor: 14,
min_workgroups: 2048,
},
GpuTier {
pattern: r"\b6[45]00\b",
name: "AMD RX 6500/6400 (RDNA 2 Entry)",
workgroup_divisor: 22,
min_workgroups: 512,
},
GpuTier {
pattern: r"rx 6\d{3}",
name: "AMD RX 6000 (RDNA 2)",
workgroup_divisor: 14,
min_workgroups: 2048,
},
// RDNA 2 APUs - check before discrete
GpuTier {
pattern: r"\b680m\b|radeon 680m",
name: "AMD Radeon 680M (RDNA 2 APU)",
workgroup_divisor: 16,
min_workgroups: 1536,
},
GpuTier {
pattern: r"\b6[16]0m\b|radeon 6[16]0m",
name: "AMD Radeon 6x0M (RDNA 2 APU)",
workgroup_divisor: 22,
min_workgroups: 768,
},
// RDNA 1 (RX 5000 series) - 4-digit patterns to avoid matching Polaris 3-digit
GpuTier {
pattern: r"\b5700\b",
name: "AMD RX 5700 (RDNA 1)",
workgroup_divisor: 16,
min_workgroups: 1536,
},
GpuTier {
pattern: r"\b5[56]00\b|rx 5\d{3}",
name: "AMD RX 5000 (RDNA 1)",
workgroup_divisor: 18,
min_workgroups: 1024,
},
// Polaris (RX 400/500 series) - 3-digit models with boundaries
GpuTier {
pattern: r"rx [45]\d0\b|\b[45][6-9]0\b|\b590\b|rx 5.0x",
name: "AMD RX 500/400 (Polaris)",
workgroup_divisor: 20,
min_workgroups: 768,
},
// Vega
GpuTier {
pattern: r"radeon vii\b",
name: "AMD Radeon VII (Vega 20)",
workgroup_divisor: 12,
min_workgroups: 2048,
},
GpuTier {
pattern: r"vega\s*64",
name: "AMD Vega 64 (Discrete)",
workgroup_divisor: 14,
min_workgroups: 1536,
},
GpuTier {
pattern: r"vega\s*56",
name: "AMD Vega 56 (Discrete)",
workgroup_divisor: 16,
min_workgroups: 1280,
},
GpuTier {
pattern: r"vega",
name: "AMD Vega (APU)",
workgroup_divisor: 28,
min_workgroups: 384,
},
// GCN
GpuTier {
pattern: r"fury|nano",
name: "AMD R9 Fury/Nano (Fiji)",
workgroup_divisor: 16,
min_workgroups: 1280,
},
GpuTier {
pattern: r"r9.*(3[89]0|2[89]0)|\b[23][89]0x?\b",
name: "AMD R9 (GCN)",
workgroup_divisor: 20,
min_workgroups: 768,
},
GpuTier {
pattern: r"r7.*(3[67]0|2[67]0)|\b[23][67]0x?\b",
name: "AMD R7 (GCN)",
workgroup_divisor: 22,
min_workgroups: 512,
},
// Professional
GpuTier {
pattern: r"radeon pro|instinct mi|mi[123]\d0|firepro|w[567]\d{3}",
name: "AMD Radeon Pro/Instinct",
workgroup_divisor: 10,
min_workgroups: 2560,
},
// OEM/APU fallbacks
GpuTier {
pattern: r"radeon\s*\(tm\)\s*[67]\d{2}\b|radeon [67]\d{2}\b",
name: "AMD Radeon OEM (Polaris Rebrand)",
workgroup_divisor: 24,
min_workgroups: 512,
},
GpuTier {
pattern: r"radeon\s*(\(tm\)\s*)?graphics",
name: "AMD Radeon Graphics (APU)",
workgroup_divisor: 26,
min_workgroups: 384,
},
];
const INTEL_TIERS: &[GpuTier] = &[
// Battlemage (Arc B-Series)
GpuTier {
pattern: r"arc b|\bb5[78]0\b",
name: "Intel Arc B-Series (Battlemage)",
workgroup_divisor: 10,
min_workgroups: 2560,
},
// Alchemist Mobile - check BEFORE desktop (a770m contains a770)
GpuTier {
pattern: r"\ba7[37]0m\b",
name: "Intel Arc A7 Mobile (Alchemist)",
workgroup_divisor: 14,
min_workgroups: 1536,
},
GpuTier {
pattern: r"\ba5[57]0m\b",
name: "Intel Arc A5 Mobile (Alchemist)",
workgroup_divisor: 16,
min_workgroups: 1024,
},
GpuTier {
pattern: r"\ba3[57]0m\b",
name: "Intel Arc A3 Mobile (Alchemist)",
workgroup_divisor: 20,
min_workgroups: 512,
},
// Alchemist Desktop
GpuTier {
pattern: r"\ba7[57]0\b",
name: "Intel Arc A7 (Alchemist)",
workgroup_divisor: 12,
min_workgroups: 2048,
},
GpuTier {
pattern: r"\ba580\b",
name: "Intel Arc A5 (Alchemist)",
workgroup_divisor: 14,
min_workgroups: 1536,
},
GpuTier {
pattern: r"\ba3[18]0\b",
name: "Intel Arc A3 (Alchemist)",
workgroup_divisor: 18,
min_workgroups: 768,
},
GpuTier {
pattern: r"arc a|\barc\b",
name: "Intel Arc (Unknown)",
workgroup_divisor: 16,
min_workgroups: 1024,
},
// Integrated
GpuTier {
pattern: r"iris xe max",
name: "Intel Iris Xe Max (Discrete)",
workgroup_divisor: 20,
min_workgroups: 512,
},
GpuTier {
pattern: r"iris xe",
name: "Intel Iris Xe (Integrated)",
workgroup_divisor: 24,
min_workgroups: 384,
},
GpuTier {
pattern: r"iris pro",
name: "Intel Iris Pro (Integrated)",
workgroup_divisor: 26,
min_workgroups: 256,
},
GpuTier {
pattern: r"iris plus|iris\b",
name: "Intel Iris Plus (Integrated)",
workgroup_divisor: 26,
min_workgroups: 320,
},
GpuTier {
pattern: r"uhd.*(7\d{2}|graphics 7)",
name: "Intel UHD 700 (Integrated)",
workgroup_divisor: 26,
min_workgroups: 320,
},
GpuTier {
pattern: r"uhd.*(6\d{2}|graphics 6)",
name: "Intel UHD 600 (Integrated)",
workgroup_divisor: 28,
min_workgroups: 256,
},
GpuTier {
pattern: r"uhd",
name: "Intel UHD Graphics (Integrated)",
workgroup_divisor: 28,
min_workgroups: 256,
},
GpuTier {
pattern: r"hd graphics|hd [456]\d{2}",
name: "Intel HD Graphics (Integrated)",
workgroup_divisor: 30,
min_workgroups: 192,
},
];
const QUALCOMM_TIERS: &[GpuTier] = &[
// Snapdragon X (Adreno X1)
GpuTier {
pattern: r"x elite|x plus|adreno x|x1-[89]",
name: "Qualcomm Adreno X1 (Snapdragon X)",
workgroup_divisor: 14,
min_workgroups: 1536,
},
// Adreno 700 series (730, 740, 750, etc.)
GpuTier {
pattern: r"adreno\s*7[0-9]{2}\b|\b7[345]0\b",
name: "Qualcomm Adreno 700 Series",
workgroup_divisor: 16,
min_workgroups: 1024,
},
// Adreno 600 series (610, 612, 615, 618, 619, 620, 630, 640, 650, 660, etc.)
GpuTier {
pattern: r"adreno\s*6[0-9]{2}\b|\b6[1-6][0-9]\b",
name: "Qualcomm Adreno 600 Series",
workgroup_divisor: 20,
min_workgroups: 512,
},
// Adreno 500 series (505, 506, 508, 509, 510, 512, 530, 540)
GpuTier {
pattern: r"adreno\s*5[0-4][0-9]\b|\b5[0-4][0-9]\b",
name: "Qualcomm Adreno 500 Series",
workgroup_divisor: 24,
min_workgroups: 384,
},
];
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",
name: "Apple M4 Ultra",
workgroup_divisor: 4,
min_workgroups: 1600,
},
GpuTier {
pattern: r"m4 max",
name: "Apple M4 Max",
workgroup_divisor: 4,
min_workgroups: 800,
},
GpuTier {
pattern: r"m4 pro",
name: "Apple M4 Pro",
workgroup_divisor: 4,
min_workgroups: 400,
},
GpuTier {
pattern: r"\bm4\b",
name: "Apple M4",
workgroup_divisor: 4,
min_workgroups: 200,
},
// M3 series
GpuTier {
pattern: r"m3 ultra",
name: "Apple M3 Ultra",
workgroup_divisor: 4,
min_workgroups: 1520,
},
GpuTier {
pattern: r"m3 max",
name: "Apple M3 Max",
workgroup_divisor: 4,
min_workgroups: 800,
},
GpuTier {
pattern: r"m3 pro",
name: "Apple M3 Pro",
workgroup_divisor: 4,
min_workgroups: 360,
},
GpuTier {
pattern: r"\bm3\b",
name: "Apple M3",
workgroup_divisor: 4,
min_workgroups: 200,
},
// M2 series
GpuTier {
pattern: r"m2 ultra",
name: "Apple M2 Ultra",
workgroup_divisor: 4,
min_workgroups: 1520,
},
GpuTier {
pattern: r"m2 max",
name: "Apple M2 Max",
workgroup_divisor: 4,
min_workgroups: 760,
},
GpuTier {
pattern: r"m2 pro",
name: "Apple M2 Pro",
workgroup_divisor: 4,
min_workgroups: 380,
},
GpuTier {
pattern: r"\bm2\b",
name: "Apple M2",
workgroup_divisor: 4,
min_workgroups: 200,
},
// M1 series
GpuTier {
pattern: r"m1 ultra",
name: "Apple M1 Ultra",
workgroup_divisor: 4,
min_workgroups: 1280,
},
GpuTier {
pattern: r"m1 max",
name: "Apple M1 Max",
workgroup_divisor: 4,
min_workgroups: 640,
},
GpuTier {
pattern: r"m1 pro",
name: "Apple M1 Pro",
workgroup_divisor: 4,
min_workgroups: 320,
},
GpuTier {
pattern: r"\bm1\b",
name: "Apple M1",
workgroup_divisor: 4,
min_workgroups: 160,
},
];
/// Compiled GPU tier tables (lazily initialized)
struct GpuTierTables {
nvidia: Vec<CompiledGpuTier>,
amd: Vec<CompiledGpuTier>,
intel: Vec<CompiledGpuTier>,
qualcomm: Vec<CompiledGpuTier>,
apple: Vec<CompiledGpuTier>,
}
static GPU_TIERS: LazyLock<GpuTierTables> = LazyLock::new(|| GpuTierTables {
nvidia: NVIDIA_TIERS
.iter()
.map(CompiledGpuTier::from_tier)
.collect(),
amd: AMD_TIERS.iter().map(CompiledGpuTier::from_tier).collect(),
intel: INTEL_TIERS.iter().map(CompiledGpuTier::from_tier).collect(),
qualcomm: QUALCOMM_TIERS
.iter()
.map(CompiledGpuTier::from_tier)
.collect(),
apple: APPLE_TIERS.iter().map(CompiledGpuTier::from_tier).collect(),
});
/// Result of GPU tier matching
pub struct GpuTierMatch {
pub name: &'static str,
pub workgroup_divisor: u32,
pub min_workgroups: u32,
pub is_fallback: bool,
}
/// Find matching tier from a list of compiled tiers
fn find_matching_tier(name: &str, tiers: &[CompiledGpuTier]) -> Option<GpuTierMatch> {
tiers
.iter()
.find(|tier| tier.regex.is_match(name))
.map(|tier| GpuTierMatch {
name: tier.name,
workgroup_divisor: tier.workgroup_divisor,
min_workgroups: tier.min_workgroups,
is_fallback: false,
})
}
/// Detect GPU tier based on adapter info
///
/// Returns (tier_name, workgroup_divisor, min_workgroups, is_fallback)
pub fn detect_gpu_tier(vendor_name: &str, vendor_id: u32, is_metal_backend: bool) -> GpuTierMatch {
let name_lower = vendor_name.to_lowercase();
// Determine vendor and find matching tier
if name_lower.contains("nvidia") || vendor_id == 0x10DE {
find_matching_tier(&name_lower, &GPU_TIERS.nvidia).unwrap_or(GpuTierMatch {
name: "NVIDIA Unknown",
workgroup_divisor: 20,
min_workgroups: 512,
is_fallback: true,
})
} else if name_lower.contains("amd") || name_lower.contains("radeon") || vendor_id == 0x1002 {
find_matching_tier(&name_lower, &GPU_TIERS.amd).unwrap_or(GpuTierMatch {
name: "AMD Unknown",
workgroup_divisor: 24,
min_workgroups: 512,
is_fallback: true,
})
} else if name_lower.contains("intel") || vendor_id == 0x8086 {
find_matching_tier(&name_lower, &GPU_TIERS.intel).unwrap_or(GpuTierMatch {
name: "Intel Unknown",
workgroup_divisor: 24,
min_workgroups: 256,
is_fallback: true,
})
} else if name_lower.contains("qualcomm")
|| name_lower.contains("adreno")
|| vendor_id == 0x5143
{
find_matching_tier(&name_lower, &GPU_TIERS.qualcomm).unwrap_or(GpuTierMatch {
name: "Qualcomm Adreno (Unknown)",
workgroup_divisor: 24,
min_workgroups: 384,
is_fallback: true,
})
} else if is_metal_backend {
// Apple Silicon - detected by Metal backend
find_matching_tier(&name_lower, &GPU_TIERS.apple).unwrap_or(GpuTierMatch {
name: "Apple Silicon Unknown",
workgroup_divisor: 4,
min_workgroups: 160,
is_fallback: true,
})
} else {
GpuTierMatch {
name: "Unknown GPU",
workgroup_divisor: 16,
min_workgroups: 512,
is_fallback: true,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_nvidia_detection() {
// RTX 40 series
let tier = detect_gpu_tier("NVIDIA GeForce RTX 4090", 0x10DE, false);
assert_eq!(tier.name, "NVIDIA RTX 40 Flagship (Ada)");
assert!(!tier.is_fallback);
let tier = detect_gpu_tier("NVIDIA GeForce RTX 4070 Ti", 0x10DE, false);
assert_eq!(tier.name, "NVIDIA RTX 40 (Ada)");
// RTX 30 series
let tier = detect_gpu_tier("NVIDIA GeForce RTX 3080", 0x10DE, false);
assert_eq!(tier.name, "NVIDIA RTX 30/20 (Ampere/Turing)");
// GTX 10 series
let tier = detect_gpu_tier("NVIDIA GeForce GTX 1080 Ti", 0x10DE, false);
assert_eq!(tier.name, "NVIDIA GTX 16/10 (Turing/Pascal)");
}
#[test]
fn test_amd_rdna_vs_polaris() {
// RDNA 1 - should NOT match Polaris
let tier = detect_gpu_tier("AMD Radeon RX 5500 XT", 0x1002, false);
assert_eq!(tier.name, "AMD RX 5000 (RDNA 1)");
assert!(!tier.is_fallback);
let tier = detect_gpu_tier("AMD Radeon RX 5600 XT", 0x1002, false);
assert_eq!(tier.name, "AMD RX 5000 (RDNA 1)");
let tier = detect_gpu_tier("AMD Radeon RX 5700 XT", 0x1002, false);
assert_eq!(tier.name, "AMD RX 5700 (RDNA 1)");
// Polaris - should match Polaris
let tier = detect_gpu_tier("AMD Radeon RX 580", 0x1002, false);
assert_eq!(tier.name, "AMD RX 500/400 (Polaris)");
let tier = detect_gpu_tier("AMD Radeon RX 560X", 0x1002, false);
assert_eq!(tier.name, "AMD RX 500/400 (Polaris)");
let tier = detect_gpu_tier("AMD Radeon RX 550", 0x1002, false);
assert_eq!(tier.name, "AMD RX 500/400 (Polaris)");
}
#[test]
fn test_amd_vega_apu() {
let tier = detect_gpu_tier("AMD Radeon(TM) Vega 8 Graphics", 0x1002, false);
assert_eq!(tier.name, "AMD Vega (APU)");
assert!(!tier.is_fallback);
}
#[test]
fn test_intel_arc_mobile_vs_desktop() {
// Mobile should match mobile tier
let tier = detect_gpu_tier("Intel Arc A770M", 0x8086, false);
assert_eq!(tier.name, "Intel Arc A7 Mobile (Alchemist)");
assert!(!tier.is_fallback);
// Desktop should match desktop tier
let tier = detect_gpu_tier("Intel Arc A770", 0x8086, false);
assert_eq!(tier.name, "Intel Arc A7 (Alchemist)");
assert!(!tier.is_fallback);
}
#[test]
fn test_apple_silicon() {
let tier = detect_gpu_tier("Apple M1 Pro", 0, true);
assert_eq!(tier.name, "Apple M1 Pro");
assert!(!tier.is_fallback);
let tier = detect_gpu_tier("Apple M3 Max", 0, true);
assert_eq!(tier.name, "Apple M3 Max");
assert!(!tier.is_fallback);
}
#[test]
fn test_qualcomm_adreno_series() {
// Adreno 627 should match 600 series, NOT 700 series
let tier = detect_gpu_tier("Qualcomm Adreno 627", 0x5143, false);
assert_eq!(tier.name, "Qualcomm Adreno 600 Series");
assert!(!tier.is_fallback);
// Adreno 730 should match 700 series
let tier = detect_gpu_tier("Qualcomm Adreno 730", 0x5143, false);
assert_eq!(tier.name, "Qualcomm Adreno 700 Series");
assert!(!tier.is_fallback);
// Adreno 540 should match 500 series
let tier = detect_gpu_tier("Qualcomm Adreno 540", 0x5143, false);
assert_eq!(tier.name, "Qualcomm Adreno 500 Series");
assert!(!tier.is_fallback);
}
}
@@ -9,7 +9,7 @@ const GOLDILOCKS_PRIME_HIGH: u32 = 4294967295u; // High 32 bits (2^32 - 1)
// Poseidon2 constants
const WIDTH: u32 = 12u;
const RATE: u32 = 4u;
const RATE: u32 = 8u;
const EXTERNAL_ROUNDS: u32 = 4u;
const INTERNAL_ROUNDS: u32 = 22u;
@@ -191,10 +191,12 @@ 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, 4>; // [total_threads (logical), nonces_per_thread, total_nonces (logical nonces in this dispatch), threads_per_workgroup]
@group(0) @binding(4) var<storage, read> dispatch_config: array<u32, 3>; // [total_threads, nonces_per_thread, total_nonces]
// Goldilocks field element represented as [limb0, limb1]
// where the value is limb0 + limb1*2^32
@@ -676,17 +678,17 @@ fn poseidon2_hash_squeeze_twice(input: array<u32, 24>) -> array<u32, 16> {
state[i] = gf_zero();
}
// Convert input to field elements (25 total)
// Convert input to field elements (25 total: 24 from input + 1 terminator)
let input_felts = bytes_to_field_elements(input);
// Sponge construction matching CPU reference exactly:
// CPU processes field elements using push_to_buf() which absorbs in chunks of RATE=4
// CPU processes field elements using push_to_buf() which absorbs in chunks of RATE=8
// Process first 24 elements (6 complete chunks of 4)
for (var chunk = 0u; chunk < 6u; chunk++) {
// Absorb 4 elements for this chunk
for (var i = 0u; i < 4u; i++) {
let felt_idx = chunk * 4u + i;
// Process first 24 elements (3 complete chunks of 8)
for (var chunk = 0u; chunk < 3u; chunk++) {
// Absorb 8 elements for this chunk
for (var i = 0u; i < 8u; i++) {
let felt_idx = chunk * 8u + i;
state[i] = gf_add(state[i], input_felts[felt_idx]);
}
// Permute after each complete chunk
@@ -694,17 +696,16 @@ fn poseidon2_hash_squeeze_twice(input: array<u32, 24>) -> array<u32, 16> {
}
// Process remaining 1 element (partial chunk)
// Now we have element 24 (the padding marker = 1) remaining
// This simulates CPU's finalize_twice adding ONE to buffer position 0
state[0] = gf_add(state[0], input_felts[24u]); // Add the padding marker (should be 1)
// Now we have element 24 (the terminator = 1) remaining
state[0] = gf_add(state[0], input_felts[24u]); // Add the terminator (value 1)
// Add sponge padding marker (ONE) to the next position
state[1] = gf_add(state[1], gf_one());
// Final permutation (CPU calls permute after completing the block)
// Final permutation
poseidon2_permute(&state);
// First squeeze - get first 4 field elements
// First squeeze - get first 4 field elements (POSEIDON2_OUTPUT=4 -> 32 bytes)
let first_output = field_elements_to_bytes(array<GoldilocksField, 4>(
state[0], state[1], state[2], state[3]
));
@@ -783,23 +784,13 @@ fn poseidon2_hash_squeeze_twice_64(input: array<u32, 16>) -> array<u32, 16> {
return result;
}
// Double Poseidon2 hash (like Bitcoin's double SHA256)
fn double_hash(input: array<u32, 24>) -> array<u32, 16> {
let first_hash = poseidon2_hash_squeeze_twice(input);
return poseidon2_hash_squeeze_twice_64(first_hash);
// Single Poseidon2 hash with double squeeze (512-bit output)
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
@@ -836,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 = double_hash(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]);
@@ -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);
}
@@ -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);
}
+116
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
);
}
}
+1044 -464
View File
File diff suppressed because it is too large Load Diff
+109 -154
View File
@@ -1,177 +1,132 @@
use futures::executor::block_on;
mod end_to_end_tests;
mod tests;
fn main() {
block_on(run()).unwrap();
}
async fn run() -> Result<(), Box<dyn std::error::Error>> {
// Setup GPU device and queue
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
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_double_hash(&device, &queue).await {
eprintln!("❌ Double hash 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
}
+95 -88
View File
@@ -1,8 +1,7 @@
use plonky2::hash::poseidon2::P2Permuter;
use qp_plonky2_field::goldilocks_field::GoldilocksField;
use qp_plonky2_field::types::{Field, Field64, PrimeField64};
use qp_poseidon_core::serialization::{p2_backend::GF as P2Goldilocks, GoldiCompat};
use qp_poseidon_core::Goldilocks as QpGoldilocks;
use rand::{Rng, SeedableRng};
use wgpu::util::DeviceExt;
@@ -204,8 +203,8 @@ fn generate_gf_mul_test_vectors() -> Vec<GfMulTestCase> {
// Random small values (both operands < 2^32)
for _ in 0..50 {
let a = GoldilocksField::from_canonical_u64(rng.gen::<u32>() as u64);
let b = GoldilocksField::from_canonical_u64(rng.gen::<u32>() as u64);
let a = GoldilocksField::from_canonical_u64(rng.random::<u32>() as u64);
let b = GoldilocksField::from_canonical_u64(rng.random::<u32>() as u64);
vectors.push(GfMulTestCase {
a,
b,
@@ -215,8 +214,8 @@ fn generate_gf_mul_test_vectors() -> Vec<GfMulTestCase> {
// Random mixed cases (one small, one large)
for _ in 0..80 {
let a = GoldilocksField::from_canonical_u64(rng.gen::<u32>() as u64);
let b = GoldilocksField::from_canonical_u64(rng.gen::<u64>());
let a = GoldilocksField::from_canonical_u64(rng.random::<u32>() as u64);
let b = GoldilocksField::from_canonical_u64(rng.random::<u64>());
vectors.push(GfMulTestCase {
a,
b,
@@ -226,8 +225,8 @@ fn generate_gf_mul_test_vectors() -> Vec<GfMulTestCase> {
// Random large values (both operands >= 2^32)
for _ in 0..100 {
let a = GoldilocksField::from_canonical_u64(rng.gen::<u64>() | 0x100000000u64);
let b = GoldilocksField::from_canonical_u64(rng.gen::<u64>() | 0x100000000u64);
let a = GoldilocksField::from_canonical_u64(rng.random::<u64>() | 0x100000000u64);
let b = GoldilocksField::from_canonical_u64(rng.random::<u64>() | 0x100000000u64);
vectors.push(GfMulTestCase {
a,
b,
@@ -237,9 +236,9 @@ fn generate_gf_mul_test_vectors() -> Vec<GfMulTestCase> {
// Random values near the field modulus
for _ in 0..30 {
let offset = rng.gen::<u32>() as u64;
let offset = rng.random::<u32>() as u64;
let a = GoldilocksField::from_canonical_u64(GoldilocksField::ORDER - offset);
let b = GoldilocksField::from_canonical_u64(rng.gen::<u64>());
let b = GoldilocksField::from_canonical_u64(rng.random::<u64>());
vectors.push(GfMulTestCase {
a,
b,
@@ -250,7 +249,7 @@ fn generate_gf_mul_test_vectors() -> Vec<GfMulTestCase> {
// Add more specific high-limb test cases to detect patterns
for i in 20..40 {
let val1 = 1u64 << i; // Powers of 2 from 2^20 to 2^39
let val2 = rng.gen::<u32>() as u64 | (1u64 << 32); // Random value with high bit set
let val2 = rng.random::<u32>() as u64 | (1u64 << 32); // Random value with high bit set
let a = GoldilocksField::from_canonical_u64(val1);
let b = GoldilocksField::from_canonical_u64(val2);
vectors.push(GfMulTestCase {
@@ -262,9 +261,9 @@ fn generate_gf_mul_test_vectors() -> Vec<GfMulTestCase> {
// Test values with specific limb patterns
for _ in 0..20 {
let high_val = (rng.gen::<u32>() as u64) << 32;
let high_val = (rng.random::<u32>() as u64) << 32;
let a = GoldilocksField::from_canonical_u64(high_val);
let b = GoldilocksField::from_canonical_u64(rng.gen::<u64>() | 0x100000000u64);
let b = GoldilocksField::from_canonical_u64(rng.random::<u64>() | 0x100000000u64);
vectors.push(GfMulTestCase {
a,
b,
@@ -331,21 +330,21 @@ fn generate_sbox_test_vectors() -> Vec<SboxTestCase> {
// Random small values (< 2^32)
for _ in 0..20 {
let input = GoldilocksField::from_canonical_u64(rng.gen::<u32>() as u64);
let input = GoldilocksField::from_canonical_u64(rng.random::<u32>() as u64);
let expected = input.exp_u64(7);
vectors.push(SboxTestCase { input, expected });
}
// Random large values (>= 2^32)
for _ in 0..30 {
let input = GoldilocksField::from_canonical_u64(rng.gen::<u64>() | 0x100000000u64);
let input = GoldilocksField::from_canonical_u64(rng.random::<u64>() | 0x100000000u64);
let expected = input.exp_u64(7);
vectors.push(SboxTestCase { input, expected });
}
// Values near the field modulus
for _ in 0..10 {
let offset = rng.gen::<u32>() as u64;
let offset = rng.random::<u32>() as u64;
let input = GoldilocksField::from_canonical_u64(GoldilocksField::ORDER - offset);
let expected = input.exp_u64(7);
vectors.push(SboxTestCase { input, expected });
@@ -387,7 +386,7 @@ fn generate_external_linear_layer_test_vectors() -> Vec<ExternalLinearLayerTestC
for _ in 0..15 {
let mut input = [GoldilocksField::ZERO; 12];
for item in &mut input {
let val = rng.gen::<u16>() as u64;
let val = rng.random::<u16>() as u64;
*item = GoldilocksField::from_canonical_u64(val);
}
let expected = apply_full_external_linear_layer_to_state(&input);
@@ -398,7 +397,7 @@ fn generate_external_linear_layer_test_vectors() -> Vec<ExternalLinearLayerTestC
for _ in 0..10 {
let mut input = [GoldilocksField::ZERO; 12];
for item in &mut input {
let val = rng.gen::<u32>() as u64;
let val = rng.random::<u32>() as u64;
*item = GoldilocksField::from_canonical_u64(val);
}
let expected = apply_full_external_linear_layer_to_state(&input);
@@ -432,7 +431,7 @@ fn generate_internal_linear_layer_test_vectors() -> Vec<InternalLinearLayerTestC
for _ in 0..10 {
let mut input = [GoldilocksField::ZERO; 12];
for item in &mut input {
*item = GoldilocksField::from_canonical_u64(rng.gen_range(0..100));
*item = GoldilocksField::from_canonical_u64(rng.random_range(0..100));
}
let expected = apply_internal_linear_layer_to_state(&input);
vectors.push(InternalLinearLayerTestCase { input, expected });
@@ -442,7 +441,7 @@ fn generate_internal_linear_layer_test_vectors() -> Vec<InternalLinearLayerTestC
for _ in 0..20 {
let mut input = [GoldilocksField::ZERO; 12];
for item in &mut input {
*item = GoldilocksField::from_noncanonical_u64(rng.gen());
*item = GoldilocksField::from_noncanonical_u64(rng.random());
}
let expected = apply_internal_linear_layer_to_state(&input);
vectors.push(InternalLinearLayerTestCase { input, expected });
@@ -487,7 +486,7 @@ fn generate_mds_test_vectors() -> Vec<MdsTestCase> {
for _ in 0..50 {
let mut input = [GoldilocksField::ZERO; 4];
for item in &mut input {
*item = GoldilocksField::from_canonical_u64(rng.gen::<u16>() as u64);
*item = GoldilocksField::from_canonical_u64(rng.random::<u16>() as u64);
}
let expected = apply_external_linear_layer_to_chunk(&input);
vectors.push(MdsTestCase { input, expected });
@@ -497,7 +496,7 @@ fn generate_mds_test_vectors() -> Vec<MdsTestCase> {
for _ in 0..30 {
let mut input = [GoldilocksField::ZERO; 4];
for item in &mut input {
*item = GoldilocksField::from_canonical_u64(rng.gen::<u32>() as u64);
*item = GoldilocksField::from_canonical_u64(rng.random::<u32>() as u64);
}
let expected = apply_external_linear_layer_to_chunk(&input);
vectors.push(MdsTestCase { input, expected });
@@ -507,7 +506,7 @@ fn generate_mds_test_vectors() -> Vec<MdsTestCase> {
for _ in 0..30 {
let mut input = [GoldilocksField::ZERO; 4];
for item in &mut input {
*item = GoldilocksField::from_canonical_u64(rng.gen::<u64>());
*item = GoldilocksField::from_canonical_u64(rng.random::<u64>());
}
let expected = apply_external_linear_layer_to_chunk(&input);
vectors.push(MdsTestCase { input, expected });
@@ -517,7 +516,7 @@ fn generate_mds_test_vectors() -> Vec<MdsTestCase> {
for _ in 0..10 {
let mut input = [GoldilocksField::ZERO; 4];
for item in &mut input {
let offset = rng.gen::<u32>() as u64;
let offset = rng.random::<u32>() as u64;
*item = GoldilocksField::from_canonical_u64(GoldilocksField::ORDER - offset);
}
let expected = apply_external_linear_layer_to_chunk(&input);
@@ -710,7 +709,7 @@ fn generate_poseidon2_test_vectors() -> Vec<Poseidon2TestCase> {
for _ in 0..15 {
let mut input = [GoldilocksField::ZERO; 12];
for item in &mut input {
let val = rng.gen::<u16>() as u64;
let val = rng.random::<u16>() as u64;
*item = GoldilocksField::from_canonical_u64(val);
}
let expected = <GoldilocksField as P2Permuter>::permute(input);
@@ -721,7 +720,7 @@ fn generate_poseidon2_test_vectors() -> Vec<Poseidon2TestCase> {
for _ in 0..20 {
let mut input = [GoldilocksField::ZERO; 12];
for item in &mut input {
let val = rng.gen::<u16>() as u64;
let val = rng.random::<u16>() as u64;
*item = GoldilocksField::from_canonical_u64(val);
}
let expected = <GoldilocksField as P2Permuter>::permute(input);
@@ -732,7 +731,7 @@ fn generate_poseidon2_test_vectors() -> Vec<Poseidon2TestCase> {
for _ in 0..15 {
let mut input = [GoldilocksField::ZERO; 12];
for item in &mut input {
let val = rng.gen::<u32>() as u64;
let val = rng.random::<u32>() as u64;
*item = GoldilocksField::from_canonical_u64(val);
}
let expected = <GoldilocksField as P2Permuter>::permute(input);
@@ -744,7 +743,7 @@ fn generate_poseidon2_test_vectors() -> Vec<Poseidon2TestCase> {
for _ in 0..15 {
let mut input = [GoldilocksField::ZERO; 12];
for item in &mut input {
let val = rng.gen_range(65536..u32::MAX) as u64;
let val = rng.random_range(65536..u32::MAX) as u64;
*item = GoldilocksField::from_canonical_u64(val);
}
let expected = <GoldilocksField as P2Permuter>::permute(input);
@@ -802,10 +801,10 @@ fn generate_field_to_bytes_test_vectors() -> Vec<FieldToBytesTestCase> {
// Test 4: Random values
for _ in 0..10 {
let input = [
GoldilocksField::from_canonical_u64(rng.gen::<u64>() % GoldilocksField::ORDER),
GoldilocksField::from_canonical_u64(rng.gen::<u64>() % GoldilocksField::ORDER),
GoldilocksField::from_canonical_u64(rng.gen::<u64>() % GoldilocksField::ORDER),
GoldilocksField::from_canonical_u64(rng.gen::<u64>() % GoldilocksField::ORDER),
GoldilocksField::from_canonical_u64(rng.random::<u64>() % GoldilocksField::ORDER),
GoldilocksField::from_canonical_u64(rng.random::<u64>() % GoldilocksField::ORDER),
GoldilocksField::from_canonical_u64(rng.random::<u64>() % GoldilocksField::ORDER),
GoldilocksField::from_canonical_u64(rng.random::<u64>() % GoldilocksField::ORDER),
];
let mut expected = [0u8; 32];
for (i, &felt) in input.iter().enumerate() {
@@ -888,13 +887,17 @@ fn generate_bytes_to_field_test_vectors() -> Vec<BytesToFieldTestCase> {
let mut vectors = Vec::new();
let mut rng = rand_chacha::ChaCha8Rng::seed_from_u64(0x98765432);
// Helper to convert qp_poseidon_core::Goldilocks to Plonky2 GoldilocksField
fn qp_to_p2(felts: Vec<QpGoldilocks>) -> Vec<GoldilocksField> {
felts
.into_iter()
.map(|g| GoldilocksField::from_noncanonical_u64(g.as_canonical_u64()))
.collect()
}
// Test 1: All zeros
let zero_input = [0u8; 96];
let zero_expected =
qp_poseidon_core::serialization::injective_bytes_to_felts::<P2Goldilocks>(&zero_input)
.into_iter()
.map(|g| GoldilocksField::from_noncanonical_u64(g.to_u64()))
.collect();
let zero_expected = qp_to_p2(qp_poseidon_core::serialization::bytes_to_felts(&zero_input));
vectors.push(BytesToFieldTestCase {
input_bytes: zero_input,
expected_felts: zero_expected,
@@ -902,11 +905,7 @@ fn generate_bytes_to_field_test_vectors() -> Vec<BytesToFieldTestCase> {
// Test 2: All 0xFF
let ff_input = [0xFFu8; 96];
let ff_expected =
qp_poseidon_core::serialization::injective_bytes_to_felts::<P2Goldilocks>(&ff_input)
.into_iter()
.map(|g| GoldilocksField::from_noncanonical_u64(g.to_u64()))
.collect();
let ff_expected = qp_to_p2(qp_poseidon_core::serialization::bytes_to_felts(&ff_input));
vectors.push(BytesToFieldTestCase {
input_bytes: ff_input,
expected_felts: ff_expected,
@@ -917,11 +916,7 @@ fn generate_bytes_to_field_test_vectors() -> Vec<BytesToFieldTestCase> {
for (i, byte) in seq_input.iter_mut().enumerate() {
*byte = (i % 256) as u8;
}
let seq_expected =
qp_poseidon_core::serialization::injective_bytes_to_felts::<P2Goldilocks>(&seq_input)
.into_iter()
.map(|g| GoldilocksField::from_noncanonical_u64(g.to_u64()))
.collect();
let seq_expected = qp_to_p2(qp_poseidon_core::serialization::bytes_to_felts(&seq_input));
vectors.push(BytesToFieldTestCase {
input_bytes: seq_input,
expected_felts: seq_expected,
@@ -931,11 +926,7 @@ fn generate_bytes_to_field_test_vectors() -> Vec<BytesToFieldTestCase> {
for _ in 0..10 {
let mut input = [0u8; 96];
rng.fill(&mut input[..]);
let expected =
qp_poseidon_core::serialization::injective_bytes_to_felts::<P2Goldilocks>(&input)
.into_iter()
.map(|g| GoldilocksField::from_noncanonical_u64(g.to_u64()))
.collect();
let expected = qp_to_p2(qp_poseidon_core::serialization::bytes_to_felts(&input));
vectors.push(BytesToFieldTestCase {
input_bytes: input,
expected_felts: expected,
@@ -1040,20 +1031,18 @@ fn print_test_failure_hex(
}
}
fn generate_double_hash_test_vectors() -> Vec<DoubleHashTestCase> {
fn generate_hash_squeeze_twice_test_vectors() -> Vec<DoubleHashTestCase> {
let mut vectors = Vec::new();
let mut rng = rand_chacha::ChaCha8Rng::seed_from_u64(0xDEADBEEF);
// Test 1-250: Random header and nonce combinations (25 tests)
// Test 1-250: Random header and nonce combinations
for i in 0..250 {
let mut input = [0u8; 96];
rng.fill(&mut input[..]);
let expected = {
let first_hash = qp_poseidon_core::hash_squeeze_twice(&input);
qp_poseidon_core::hash_squeeze_twice(&first_hash)
};
// Single hash with double squeeze (no longer double hash)
let expected = qp_poseidon_core::hash_squeeze_twice(&input);
vectors.push(DoubleHashTestCase {
input_96_bytes: input,
@@ -1145,7 +1134,7 @@ fn generate_true_internal_only_test_vectors() -> Vec<InternalRoundsTestCase> {
for _ in 0..15 {
let mut random_input = [GoldilocksField::ZERO; 12];
for item in &mut random_input {
let val = (rng.gen::<u8>()) as u64;
let val = (rng.random::<u8>()) as u64;
*item = GoldilocksField::from_canonical_u64(val);
}
let after_initial_external = apply_initial_external(random_input);
@@ -1160,7 +1149,7 @@ fn generate_true_internal_only_test_vectors() -> Vec<InternalRoundsTestCase> {
for _ in 0..10 {
let mut random_input = [GoldilocksField::ZERO; 12];
for item in &mut random_input {
let val = (rng.gen::<u32>()) as u64;
let val = (rng.random::<u32>()) as u64;
*item = GoldilocksField::from_canonical_u64(val);
}
let after_initial_external = apply_initial_external(random_input);
@@ -1177,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();
@@ -1213,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 {
@@ -1381,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();
@@ -1426,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 {
@@ -1471,7 +1462,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
for _ in 0..15 {
let mut state = [GoldilocksField::ZERO; 12];
for item in &mut state {
let val = (rng.gen::<u32>()) as u64;
let val = (rng.random::<u32>()) as u64;
*item = GoldilocksField::from_canonical_u64(val);
}
test_cases.push(state);
@@ -1481,7 +1472,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
for _ in 0..10 {
let mut state = [GoldilocksField::ZERO; 12];
for item in &mut state {
let val = (rng.gen::<u16>()) as u64;
let val = (rng.random::<u16>()) as u64;
*item = GoldilocksField::from_canonical_u64(val);
}
test_cases.push(state);
@@ -1641,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;
@@ -1685,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 {
@@ -1730,7 +1722,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
for _ in 0..15 {
let mut state = [GoldilocksField::ZERO; 12];
for item in &mut state {
let val = (rng.gen::<u8>()) as u64;
let val = (rng.random::<u8>()) as u64;
*item = GoldilocksField::from_canonical_u64(val);
}
test_cases.push(state);
@@ -1740,7 +1732,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
for _ in 0..10 {
let mut state = [GoldilocksField::ZERO; 12];
for item in &mut state {
let val = (rng.gen::<u16>()) as u64;
let val = (rng.random::<u16>()) as u64;
*item = GoldilocksField::from_canonical_u64(val);
}
test_cases.push(state);
@@ -1899,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();
@@ -1919,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 {
@@ -2034,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();
@@ -2060,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 {
@@ -2171,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();
@@ -2220,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 {
@@ -2354,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();
@@ -2375,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 {
@@ -2473,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...");
@@ -2513,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 {
@@ -2641,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();
@@ -2667,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 {
@@ -2775,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();
@@ -2797,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 {
@@ -2958,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();
@@ -2966,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!(
"
{}
@@ -3226,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();
@@ -3234,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!(
"
{}
@@ -3459,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();
@@ -3496,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 {
@@ -3636,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();
@@ -3673,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()
);
@@ -3818,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();
@@ -3853,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 {
@@ -3996,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();
@@ -4031,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 {
@@ -4179,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();
@@ -4214,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 {
@@ -4344,11 +4350,12 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
results.print_summary()
}
pub async fn test_double_hash(
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_double_hash_test_vectors();
let test_vectors = generate_hash_squeeze_twice_test_vectors();
let total_tests = test_vectors.len();
let mut passed_tests = 0;
let mut failed_tests = Vec::new();
@@ -4372,8 +4379,8 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
input_array[i] = input_data[input_offset + i];
}}
// Hash the input using double hash (96 bytes -> 64 bytes)
let hash_result = double_hash(input_array);
// Hash the input using single hash with double squeeze (96 bytes -> 64 bytes)
let hash_result = hash_squeeze_twice(input_array);
// Write full 64 bytes (16 u32s) of result as the hash
let output_offset = test_id * 16u;
@@ -4382,16 +4389,16 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
}}
}}
",
include_str!("mining.wgsl")
shader_src
);
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Double Hash Test Shader"),
label: Some("Hash Squeeze Twice Test Shader"),
source: wgpu::ShaderSource::Wgsl(shader_source.into()),
});
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("Double Hash Test Pipeline"),
label: Some("Hash Squeeze Twice Test Pipeline"),
layout: None,
module: &shader,
entry_point: Some("main"),
+2 -6
View File
@@ -6,11 +6,9 @@ publish = false
description = "Metrics and Prometheus exporter utilities for the Quantus External Miner"
[features]
# Provide a Registry by default; HTTP exporter is opt-in.
default = ["registry"]
registry = []
default = []
# Enable a Warp-based HTTP exporter (used when --metrics-port is provided).
http-exporter = ["dep:serde", "dep:serde_json", "dep:tokio", "dep:warp"]
http-exporter = ["dep:tokio", "dep:warp"]
[dependencies]
anyhow = { workspace = true }
@@ -21,5 +19,3 @@ prometheus = { version = "0.13", default-features = false }
# Optional HTTP exporter stack (enabled via `http-exporter` feature)
warp = { workspace = true, optional = true }
tokio = { workspace = true, features = ["full"], optional = true }
serde = { workspace = true, features = ["derive"], optional = true }
serde_json = { workspace = true, optional = true }
+213 -674
View File
@@ -1,55 +1,91 @@
#![deny(rust_2018_idioms)]
#![forbid(unsafe_code)]
//! Minimal metrics scaffolding for the Quantus External Miner.
//! Minimal metrics for the Quantus External Miner.
//!
//! - Provides a global Prometheus registry and a handful of default metrics.
//! - Exposes helper functions to update metrics from the service layer.
//! - Optionally runs a Warp-based HTTP endpoint (/metrics) when the
//! `http-exporter` feature is enabled. This is gated at runtime by
//! the presence of a metrics port in the CLI (`--metrics-port`).
//! Exposes a small set of Prometheus metrics for monitoring mining performance:
//! - `miner_hash_rate`: Total hash rate (CPU + GPU combined)
//! - `miner_cpu_hash_rate`: CPU-only hash rate
//! - `miner_gpu_hash_rate`: GPU-only hash rate
//! - `miner_hashes_total`: Total hashes computed (all time)
//! - `miner_active_jobs`: Currently running jobs (0 or 1)
//! - `miner_workers`: Total worker count
//! - `miner_cpu_workers`: Number of CPU workers
//! - `miner_gpu_devices`: Number of GPU devices
//! - `miner_effective_cpus`: Detected CPU cores
//!
//! Default metrics:
//! - miner_jobs_total{status} : number of jobs by terminal state
//! - miner_hashes_total : total nonces tested
//! - miner_hash_rate : current estimated hash rate (nonces/sec)
//! - miner_http_requests_total{code,endpoint} : HTTP request counts for miner API
//!
//! Notes:
//! - The HTTP exporter is spawned as a background task and does not block.
//! - If the `http-exporter` feature is disabled, `start_http_exporter` becomes
//! a no-op that returns immediately.
//! - When jobs/threads end, prefer removing gauge label children (series) rather than
//! writing a zero; this avoids scrape-timing artifacts and produces cleaner rollups.
//! - Service-level observability: expose an `active_jobs` gauge and a `mine_requests_total`
//! counter (labeled by result) to disambiguate "idle because no jobs" vs "actively mining".
//! Optionally runs a Warp-based HTTP endpoint (`/metrics`) when the
//! `http-exporter` feature is enabled.
use once_cell::sync::Lazy;
use prometheus::{
opts, Encoder, Gauge, GaugeVec, IntCounter, IntCounterVec, IntGauge, IntGaugeVec, Registry,
TextEncoder,
};
use prometheus::{IntCounter, IntGauge, Registry};
use std::sync::Mutex;
use std::time::Instant;
#[cfg(feature = "http-exporter")]
use {anyhow::Result, std::net::SocketAddr, warp::Filter};
use {
anyhow::Result,
prometheus::{Encoder, TextEncoder},
std::net::SocketAddr,
warp::Filter,
};
#[cfg(not(feature = "http-exporter"))]
use anyhow::Result;
use std::collections::{HashMap, HashSet};
use std::sync::Mutex;
use std::thread;
use std::time::{Duration, Instant};
// -------------------------------------------------------------------------------------
// Global Registry and Default Metrics
// -------------------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Global Registry
// ---------------------------------------------------------------------------
static REGISTRY: Lazy<Registry> = Lazy::new(Registry::new);
// -------------------------------------------------------------------------------------
// High-level service metrics
// -------------------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Hash Rate Metrics
// ---------------------------------------------------------------------------
static HASH_RATE: Lazy<IntGauge> = Lazy::new(|| {
let g = IntGauge::new("miner_hash_rate", "Total hash rate in hashes per second")
.expect("create miner_hash_rate");
REGISTRY
.register(Box::new(g.clone()))
.expect("register miner_hash_rate");
g
});
static CPU_HASH_RATE: Lazy<IntGauge> = Lazy::new(|| {
let g = IntGauge::new("miner_cpu_hash_rate", "CPU hash rate in hashes per second")
.expect("create miner_cpu_hash_rate");
REGISTRY
.register(Box::new(g.clone()))
.expect("register miner_cpu_hash_rate");
g
});
static GPU_HASH_RATE: Lazy<IntGauge> = Lazy::new(|| {
let g = IntGauge::new("miner_gpu_hash_rate", "GPU hash rate in hashes per second")
.expect("create miner_gpu_hash_rate");
REGISTRY
.register(Box::new(g.clone()))
.expect("register miner_gpu_hash_rate");
g
});
// ---------------------------------------------------------------------------
// Counter Metrics
// ---------------------------------------------------------------------------
static HASHES_TOTAL: Lazy<IntCounter> = Lazy::new(|| {
let c = IntCounter::new("miner_hashes_total", "Total hashes computed")
.expect("create miner_hashes_total");
REGISTRY
.register(Box::new(c.clone()))
.expect("register miner_hashes_total");
c
});
// ---------------------------------------------------------------------------
// Gauge Metrics
// ---------------------------------------------------------------------------
static ACTIVE_JOBS: Lazy<IntGauge> = Lazy::new(|| {
let g = IntGauge::new(
@@ -63,25 +99,37 @@ static ACTIVE_JOBS: Lazy<IntGauge> = Lazy::new(|| {
g
});
static ENGINE_BACKEND_INFO: Lazy<GaugeVec> = Lazy::new(|| {
let g = GaugeVec::new(
opts!(
"miner_engine_backend",
"Engine backend info (label-only gauge set to 1). Labels: engine, backend"
),
&["engine", "backend"],
)
.expect("create miner_engine_backend");
static WORKERS: Lazy<IntGauge> = Lazy::new(|| {
let g = IntGauge::new("miner_workers", "Total number of worker threads")
.expect("create miner_workers");
REGISTRY
.register(Box::new(g.clone()))
.expect("register miner_engine_backend");
.expect("register miner_workers");
g
});
static CPU_WORKERS: Lazy<IntGauge> = Lazy::new(|| {
let g = IntGauge::new("miner_cpu_workers", "Number of CPU worker threads")
.expect("create miner_cpu_workers");
REGISTRY
.register(Box::new(g.clone()))
.expect("register miner_cpu_workers");
g
});
static GPU_DEVICES: Lazy<IntGauge> = Lazy::new(|| {
let g = IntGauge::new("miner_gpu_devices", "Number of GPU devices")
.expect("create miner_gpu_devices");
REGISTRY
.register(Box::new(g.clone()))
.expect("register miner_gpu_devices");
g
});
static EFFECTIVE_CPUS: Lazy<IntGauge> = Lazy::new(|| {
let g = IntGauge::new(
"miner_effective_cpus",
"Detected logical CPU capacity available to this process (cpuset-aware)",
"Detected logical CPU cores available to this process",
)
.expect("create miner_effective_cpus");
REGISTRY
@@ -90,676 +138,167 @@ static EFFECTIVE_CPUS: Lazy<IntGauge> = Lazy::new(|| {
g
});
static MINE_REQUESTS_TOTAL: Lazy<IntCounterVec> = Lazy::new(|| {
let c = IntCounterVec::new(
opts!(
"miner_mine_requests_total",
"Count of /mine requests by result"
),
&["result"], // accepted, duplicate, invalid, error
)
.expect("create miner_mine_requests_total");
REGISTRY
.register(Box::new(c.clone()))
.expect("register miner_mine_requests_total");
c
});
// ---------------------------------------------------------------------------
// Hash Rate Tracking
// ---------------------------------------------------------------------------
static JOBS_TOTAL: Lazy<IntCounterVec> = Lazy::new(|| {
let c = IntCounterVec::new(
opts!("miner_jobs_total", "Number of jobs by status"),
&["status"],
)
.expect("create miner_jobs_total");
REGISTRY
.register(Box::new(c.clone()))
.expect("register miner_jobs_total");
c
});
static HASHES_TOTAL: Lazy<IntCounter> = Lazy::new(|| {
let c = IntCounter::new("miner_hashes_total", "Total nonces tested")
.expect("create miner_hashes_total");
REGISTRY
.register(Box::new(c.clone()))
.expect("register miner_hashes_total");
c
});
static HASH_RATE: Lazy<Gauge> = Lazy::new(|| {
let g =
Gauge::new("miner_hash_rate", "Estimated hash rate (nonces per second)").expect("create");
REGISTRY
.register(Box::new(g.clone()))
.expect("register miner_hash_rate");
g
});
static HTTP_REQUESTS_TOTAL: Lazy<IntCounterVec> = Lazy::new(|| {
let c = IntCounterVec::new(
opts!(
"miner_http_requests_total",
"HTTP requests count by endpoint and status code"
),
&["endpoint", "code"],
)
.expect("create miner_http_requests_total");
REGISTRY
.register(Box::new(c.clone()))
.expect("register miner_http_requests_total");
c
});
/// Access the global Prometheus registry used by this crate.
// Engine-aware, per-job, and per-thread labeled metrics
pub fn set_effective_cpus(n: i64) {
EFFECTIVE_CPUS.set(n);
/// Tracks cumulative hashes to compute rolling hash rates.
///
/// The hash rate is computed as total_hashes / elapsed_time since mining started.
/// Call `reset()` when a new mining session begins to get accurate rates.
struct HashRateTracker {
/// Cumulative CPU hashes since last reset
cpu_total: u64,
/// Cumulative GPU hashes since last reset
gpu_total: u64,
/// When tracking started - set via reset() when mining begins
start_time: Option<Instant>,
}
static JOB_HASHES_TOTAL: Lazy<IntCounterVec> = Lazy::new(|| {
let c = IntCounterVec::new(
opts!(
"miner_job_hashes_total",
"Total nonces tested per job and engine"
),
&["engine", "job_id"],
)
.expect("create miner_job_hashes_total");
REGISTRY
.register(Box::new(c.clone()))
.expect("register miner_job_hashes_total");
c
});
impl HashRateTracker {
fn new() -> Self {
Self {
cpu_total: 0,
gpu_total: 0,
start_time: None,
}
}
static JOB_HASH_RATE: Lazy<GaugeVec> = Lazy::new(|| {
let g = GaugeVec::new(
opts!(
"miner_job_hash_rate",
"Estimated hash rate (nonces per second) per job and engine"
),
&["engine", "job_id"],
)
.expect("create miner_job_hash_rate");
REGISTRY
.register(Box::new(g.clone()))
.expect("register miner_job_hash_rate");
g
});
/// Reset the tracker - call this when a new mining session starts.
fn reset(&mut self) {
self.cpu_total = 0;
self.gpu_total = 0;
self.start_time = Some(Instant::now());
}
static JOB_ESTIMATED_RATE: Lazy<GaugeVec> = Lazy::new(|| {
let g = GaugeVec::new(
opts!(
"miner_job_estimated_rate",
"Estimated work rate (nonces per second) per engine and backend"
),
&["engine", "backend"],
)
.expect("create miner_job_estimated_rate");
REGISTRY
.register(Box::new(g.clone()))
.expect("register miner_job_estimated_rate");
g
});
fn record_cpu(&mut self, hashes: u64) {
self.cpu_total += hashes;
self.update_rates();
}
static CANDIDATES_FOUND_TOTAL: Lazy<IntCounterVec> = Lazy::new(|| {
let c = IntCounterVec::new(
opts!(
"miner_candidates_found_total",
"Total candidates found per job and engine"
),
&["engine", "job_id"],
)
.expect("create miner_candidates_found_total");
REGISTRY
.register(Box::new(c.clone()))
.expect("register miner_candidates_found_total");
c
});
fn record_gpu(&mut self, hashes: u64) {
self.gpu_total += hashes;
self.update_rates();
}
static CANDIDATES_FALSE_POSITIVE_TOTAL: Lazy<IntCounterVec> = Lazy::new(|| {
let c = IntCounterVec::new(
opts!(
"miner_candidates_false_positive_total",
"Total false-positive candidates rejected by host re-verification per engine"
),
&["engine"],
)
.expect("create miner_candidates_false_positive_total");
REGISTRY
.register(Box::new(c.clone()))
.expect("register miner_candidates_false_positive_total");
c
});
static SAMPLE_MISMATCH_TOTAL: Lazy<IntCounterVec> = Lazy::new(|| {
let c = IntCounterVec::new(
opts!(
"miner_sample_mismatch_total",
"Total decision parity mismatches between engine and host per engine"
),
&["engine"],
)
.expect("create miner_sample_mismatch_total");
REGISTRY
.register(Box::new(c.clone()))
.expect("register miner_sample_mismatch_total");
c
});
static MISSED_WINNER_TOTAL: Lazy<IntCounter> = Lazy::new(|| {
let c = IntCounter::new(
"miner_gpu_g2_missed_winner_total",
"Host detected a winner in G2 batch sample but device did not flag early-exit",
)
.expect("create miner_gpu_g2_missed_winner_total");
REGISTRY
.register(Box::new(c.clone()))
.expect("register miner_gpu_g2_missed_winner_total");
c
});
static FOUND_BY_ORIGIN_TOTAL: Lazy<IntCounterVec> = Lazy::new(|| {
let c = IntCounterVec::new(
opts!(
"miner_found_by_origin_total",
"Count of candidates found by origin per engine"
),
&["engine", "origin"],
)
.expect("create miner_found_by_origin_total");
REGISTRY
.register(Box::new(c.clone()))
.expect("register miner_found_by_origin_total");
c
});
static THREAD_HASHES_TOTAL: Lazy<IntCounterVec> = Lazy::new(|| {
let c = IntCounterVec::new(
opts!(
"miner_thread_hashes_total",
"Total nonces tested per thread, job, and engine"
),
&["engine", "job_id", "thread_id"],
)
.expect("create miner_thread_hashes_total");
REGISTRY
.register(Box::new(c.clone()))
.expect("register miner_thread_hashes_total");
c
});
static THREAD_HASH_RATE: Lazy<GaugeVec> = Lazy::new(|| {
let g = GaugeVec::new(
opts!(
"miner_thread_hash_rate",
"Estimated hash rate (nonces per second) per thread, job, and engine"
),
&["engine", "job_id", "thread_id"],
)
.expect("create miner_thread_hash_rate");
REGISTRY
.register(Box::new(g.clone()))
.expect("register miner_thread_hash_rate");
g
});
static JOBS_BY_ENGINE_TOTAL: Lazy<IntCounterVec> = Lazy::new(|| {
let c = IntCounterVec::new(
opts!(
"miner_jobs_by_engine_total",
"Number of jobs by engine and terminal status"
),
&["engine", "status"],
)
.expect("create miner_jobs_by_engine_total");
REGISTRY
.register(Box::new(c.clone()))
.expect("register miner_jobs_by_engine_total");
c
});
static JOB_STATUS_GAUGE: Lazy<IntGaugeVec> = Lazy::new(|| {
let g = IntGaugeVec::new(
opts!(
"miner_job_status",
"Job status gauge (set to 1 for current status)"
),
&["engine", "job_id", "status"],
)
.expect("create miner_job_status");
REGISTRY
.register(Box::new(g.clone()))
.expect("register miner_job_status");
g
});
static JOB_FOUND_ORIGIN: Lazy<GaugeVec> = Lazy::new(|| {
let g = GaugeVec::new(
opts!(
"miner_job_found_origin",
"Per-job found candidate origin (0=unknown, 1=cpu, 2=gpu-g1, 3=gpu-g2)"
),
&["engine", "job_id", "origin"],
)
.expect("create miner_job_found_origin");
REGISTRY
.register(Box::new(g.clone()))
.expect("register miner_job_found_origin");
g
});
// -------------------------------------------------------------------------------------
// GPU-specific metrics
// -------------------------------------------------------------------------------------
static GPU_DEVICE_COUNT: Lazy<IntGauge> = Lazy::new(|| {
let g = IntGauge::new(
"miner_gpu_devices_total",
"Number of GPU devices available for mining",
)
.expect("create miner_gpu_devices_total");
REGISTRY
.register(Box::new(g.clone()))
.expect("register miner_gpu_devices_total");
g
});
static GPU_DEVICE_INFO: Lazy<GaugeVec> = Lazy::new(|| {
let g = GaugeVec::new(
opts!(
"miner_gpu_device_info",
"GPU device information (label-only gauge set to 1). Labels: device_id, name, backend, vendor, device_type"
),
&["device_id", "name", "backend", "vendor", "device_type"],
)
.expect("create miner_gpu_device_info");
REGISTRY
.register(Box::new(g.clone()))
.expect("register miner_gpu_device_info");
g
});
static GPU_BATCH_SIZE: Lazy<GaugeVec> = Lazy::new(|| {
let g = GaugeVec::new(
opts!(
"miner_gpu_batch_size",
"Current GPU batch size (hashes per batch) per device"
),
&["device_id"],
)
.expect("create miner_gpu_batch_size");
REGISTRY
.register(Box::new(g.clone()))
.expect("register miner_gpu_batch_size");
g
});
static GPU_WORKGROUPS: Lazy<GaugeVec> = Lazy::new(|| {
let g = GaugeVec::new(
opts!(
"miner_gpu_workgroups",
"Number of GPU workgroups dispatched per device"
),
&["device_id"],
)
.expect("create miner_gpu_workgroups");
REGISTRY
.register(Box::new(g.clone()))
.expect("register miner_gpu_workgroups");
g
});
pub fn default_registry() -> &'static Registry {
&REGISTRY
fn update_rates(&self) {
if let Some(start) = self.start_time {
let elapsed = start.elapsed().as_secs_f64();
// Require at least 0.1 seconds of data for meaningful rate
if elapsed >= 0.1 {
let cpu_rate = (self.cpu_total as f64 / elapsed) as i64;
let gpu_rate = (self.gpu_total as f64 / elapsed) as i64;
CPU_HASH_RATE.set(cpu_rate);
GPU_HASH_RATE.set(gpu_rate);
HASH_RATE.set(cpu_rate + gpu_rate);
}
}
}
}
// -------------------------------------------------------------------------------------
// Update Helpers (to be called from the service layer)
// -------------------------------------------------------------------------------------
static HASH_TRACKER: Lazy<Mutex<HashRateTracker>> =
Lazy::new(|| Mutex::new(HashRateTracker::new()));
/// Increment the total hashes counter by `n` (number of nonces tested).
// Labeled helpers for engine/job/thread metrics
//
// Service-level helpers
pub fn set_active_jobs(n: i64) {
ACTIVE_JOBS.set(n);
// ---------------------------------------------------------------------------
// Public API - Hash Recording
// ---------------------------------------------------------------------------
/// Reset the hash rate tracker - call this when mining starts.
/// This resets the cumulative hash counts and starts a fresh timing window.
pub fn reset_hash_tracker() {
if let Ok(mut tracker) = HASH_TRACKER.lock() {
tracker.reset();
}
}
pub fn set_engine_backend(engine: &str, backend: &str) {
ENGINE_BACKEND_INFO
.with_label_values(&[engine, backend])
.set(1.0);
/// Record CPU hashes and update hash rate metrics.
pub fn record_cpu_hashes(n: u64) {
HASHES_TOTAL.inc_by(n);
if let Ok(mut tracker) = HASH_TRACKER.lock() {
tracker.record_cpu(n);
}
}
pub fn inc_mine_requests(result: &str) {
MINE_REQUESTS_TOTAL.with_label_values(&[result]).inc();
/// Record GPU hashes and update hash rate metrics.
pub fn record_gpu_hashes(n: u64) {
HASHES_TOTAL.inc_by(n);
if let Ok(mut tracker) = HASH_TRACKER.lock() {
tracker.record_gpu(n);
}
}
pub fn inc_job_hashes(engine: &str, job_id: &str, n: u64) {
touch_job(engine, job_id);
JOB_HASHES_TOTAL
.with_label_values(&[engine, job_id])
.inc_by(n);
// ---------------------------------------------------------------------------
// Public API - Hash Rates (for direct setting, kept for compatibility)
// ---------------------------------------------------------------------------
/// Set the total hash rate (CPU + GPU combined).
pub fn set_hash_rate(rate: i64) {
HASH_RATE.set(rate);
}
pub fn set_job_hash_rate(engine: &str, job_id: &str, rate: f64) {
touch_job(engine, job_id);
JOB_HASH_RATE.with_label_values(&[engine, job_id]).set(rate);
/// Set the CPU-only hash rate.
pub fn set_cpu_hash_rate(rate: i64) {
CPU_HASH_RATE.set(rate);
}
pub fn set_job_estimated_rate_backend(engine: &str, backend: &str, rate: f64) {
JOB_ESTIMATED_RATE
.with_label_values(&[engine, backend])
.set(rate);
/// Set the GPU-only hash rate.
pub fn set_gpu_hash_rate(rate: i64) {
GPU_HASH_RATE.set(rate);
}
pub fn set_job_estimated_rate(engine: &str, _job_id: &str, rate: f64) {
// Compatibility shim: map per-job series to backend="unknown" to avoid cardinality growth
JOB_ESTIMATED_RATE
.with_label_values(&[engine, "unknown"])
.set(rate);
}
pub fn inc_candidates_found(engine: &str, job_id: &str) {
touch_job(engine, job_id);
CANDIDATES_FOUND_TOTAL
.with_label_values(&[engine, job_id])
.inc();
}
pub fn inc_candidates_false_positive(engine: &str) {
CANDIDATES_FALSE_POSITIVE_TOTAL
.with_label_values(&[engine])
.inc();
}
pub fn inc_sample_mismatch(engine: &str) {
SAMPLE_MISMATCH_TOTAL.with_label_values(&[engine]).inc();
}
pub fn inc_found_by_origin(engine: &str, origin: &str) {
FOUND_BY_ORIGIN_TOTAL
.with_label_values(&[engine, origin])
.inc();
}
pub fn inc_gpu_g2_missed_winner() {
MISSED_WINNER_TOTAL.inc();
}
pub fn set_job_found_origin(engine: &str, job_id: &str, origin: &str) {
touch_job(engine, job_id);
JOB_FOUND_ORIGIN
.with_label_values(&[engine, job_id, origin])
.set(1.0);
}
pub fn job_estimated_rate_backend(engine: &str, backend: &str, rate: f64) {
JOB_ESTIMATED_RATE
.with_label_values(&[engine, backend])
.set(rate);
}
pub fn job_estimated_rate(engine: &str, _job_id: &str, rate: f64) {
// Compatibility shim: prefer new API using backend; fallback to backend="unknown"
JOB_ESTIMATED_RATE
.with_label_values(&[engine, "unknown"])
.set(rate);
}
pub fn inc_thread_hashes(engine: &str, job_id: &str, thread_id: &str, n: u64) {
THREAD_HASHES_TOTAL
.with_label_values(&[engine, job_id, thread_id])
.inc_by(n);
}
pub fn set_thread_hash_rate(engine: &str, job_id: &str, thread_id: &str, rate: f64) {
touch_thread(engine, job_id, thread_id);
THREAD_HASH_RATE
.with_label_values(&[engine, job_id, thread_id])
.set(rate);
}
pub fn inc_jobs_by_engine(engine: &str, status: &str) {
JOBS_BY_ENGINE_TOTAL
.with_label_values(&[engine, status])
.inc();
}
pub fn set_job_status_gauge(engine: &str, job_id: &str, status: &str, value: i64) {
touch_job(engine, job_id);
JOB_STATUS_GAUGE
.with_label_values(&[engine, job_id, status])
.set(value);
}
// ---------------------------------------------------------------------------
// Public API - Counters (kept for compatibility)
// ---------------------------------------------------------------------------
/// Increment the total hashes counter (without updating rates).
/// Prefer `record_cpu_hashes` or `record_gpu_hashes` instead.
pub fn inc_hashes(n: u64) {
HASHES_TOTAL.inc_by(n);
}
/// Set the current estimated hash rate (nonces per second).
pub fn set_hash_rate(rate: f64) {
HASH_RATE.set(rate);
// ---------------------------------------------------------------------------
// Public API - Gauges
// ---------------------------------------------------------------------------
/// Set the number of active jobs (0 or 1).
pub fn set_active_jobs(n: i64) {
ACTIVE_JOBS.set(n);
}
/// Increment the jobs counter for a terminal status: completed | failed | cancelled.
pub fn inc_job_status(status: &str) {
JOBS_TOTAL.with_label_values(&[status]).inc();
/// Set the total number of workers.
pub fn set_workers(n: i64) {
WORKERS.set(n);
}
/// Increment HTTP request counters for an endpoint with a status code.
pub fn inc_http_request(endpoint: &str, code: u16) {
HTTP_REQUESTS_TOTAL
.with_label_values(&[endpoint, &code.to_string()])
.inc();
/// Set the number of CPU workers.
pub fn set_cpu_workers(n: i64) {
CPU_WORKERS.set(n);
}
// -------------------------------------------------------------------------------------
// Removal helpers for end-of-life series
// -------------------------------------------------------------------------------------
const METRICS_TTL_SECS: u64 = 300;
const JANITOR_INTERVAL_SECS: u64 = 60;
type JobKey = (String, String); // (engine, job_id)
type ThreadKey = (String, String, String); // (engine, job_id, thread_id)
static JOB_KEYS: Lazy<Mutex<HashSet<JobKey>>> = Lazy::new(|| Mutex::new(HashSet::new()));
static JOB_LAST: Lazy<Mutex<HashMap<JobKey, Instant>>> = Lazy::new(|| Mutex::new(HashMap::new()));
static THREAD_KEYS: Lazy<Mutex<HashSet<ThreadKey>>> = Lazy::new(|| Mutex::new(HashSet::new()));
static THREAD_LAST: Lazy<Mutex<HashMap<ThreadKey, Instant>>> =
Lazy::new(|| Mutex::new(HashMap::new()));
static JANITOR_INIT: Lazy<()> = Lazy::new(|| {
thread::spawn(|| loop {
thread::sleep(Duration::from_secs(JANITOR_INTERVAL_SECS));
prune_stale();
});
});
fn prune_stale() {
let now = Instant::now();
// Jobs
let mut to_remove_jobs: Vec<JobKey> = Vec::new();
{
let last = JOB_LAST.lock().unwrap();
for (k, ts) in last.iter() {
if now.duration_since(*ts).as_secs() > METRICS_TTL_SECS {
to_remove_jobs.push(k.clone());
}
}
}
for (engine, job_id) in to_remove_jobs {
let _ = JOB_HASHES_TOTAL.remove_label_values(&[&engine, &job_id]);
let _ = JOB_HASH_RATE.remove_label_values(&[&engine, &job_id]);
let _ = CANDIDATES_FOUND_TOTAL.remove_label_values(&[&engine, &job_id]);
let _ = JOB_STATUS_GAUGE.remove_label_values(&[&engine, &job_id, "completed"]);
let _ = JOB_STATUS_GAUGE.remove_label_values(&[&engine, &job_id, "failed"]);
let _ = JOB_STATUS_GAUGE.remove_label_values(&[&engine, &job_id, "cancelled"]);
// Remove per-job origin gauge variants (known origins)
let _ = JOB_FOUND_ORIGIN.remove_label_values(&[&engine, &job_id, "cpu"]);
let _ = JOB_FOUND_ORIGIN.remove_label_values(&[&engine, &job_id, "gpu-g1"]);
let _ = JOB_FOUND_ORIGIN.remove_label_values(&[&engine, &job_id, "gpu-g2"]);
JOB_KEYS
.lock()
.unwrap()
.remove(&(engine.clone(), job_id.clone()));
JOB_LAST.lock().unwrap().remove(&(engine, job_id));
}
// Threads
let mut to_remove_threads: Vec<ThreadKey> = Vec::new();
{
let last = THREAD_LAST.lock().unwrap();
for (k, ts) in last.iter() {
if now.duration_since(*ts).as_secs() > METRICS_TTL_SECS {
to_remove_threads.push(k.clone());
}
}
}
for (engine, job_id, thread_id) in to_remove_threads {
let _ = THREAD_HASHES_TOTAL.remove_label_values(&[&engine, &job_id, &thread_id]);
let _ = THREAD_HASH_RATE.remove_label_values(&[&engine, &job_id, &thread_id]);
THREAD_KEYS
.lock()
.unwrap()
.remove(&(engine.clone(), job_id.clone(), thread_id.clone()));
THREAD_LAST
.lock()
.unwrap()
.remove(&(engine, job_id, thread_id));
}
/// Set the number of GPU devices.
pub fn set_gpu_devices(n: i64) {
GPU_DEVICES.set(n);
}
fn touch_job(engine: &str, job_id: &str) {
*JANITOR_INIT; // ensure janitor starts
let key = (engine.to_string(), job_id.to_string());
JOB_KEYS.lock().unwrap().insert(key.clone());
JOB_LAST.lock().unwrap().insert(key, Instant::now());
/// Set the detected CPU core count.
pub fn set_effective_cpus(n: i64) {
EFFECTIVE_CPUS.set(n);
}
fn touch_thread(engine: &str, job_id: &str, thread_id: &str) {
*JANITOR_INIT; // ensure janitor starts
let key = (
engine.to_string(),
job_id.to_string(),
thread_id.to_string(),
);
THREAD_KEYS.lock().unwrap().insert(key.clone());
THREAD_LAST.lock().unwrap().insert(key, Instant::now());
}
// ---------------------------------------------------------------------------
// HTTP Exporter
// ---------------------------------------------------------------------------
/// Explicitly remove all job-scoped metrics for a job (call on job completion)
pub fn remove_job_metrics(engine: &str, job_id: &str) {
let _ = JOB_HASHES_TOTAL.remove_label_values(&[engine, job_id]);
let _ = JOB_HASH_RATE.remove_label_values(&[engine, job_id]);
let _ = CANDIDATES_FOUND_TOTAL.remove_label_values(&[engine, job_id]);
let _ = JOB_STATUS_GAUGE.remove_label_values(&[engine, job_id, "completed"]);
let _ = JOB_STATUS_GAUGE.remove_label_values(&[engine, job_id, "failed"]);
let _ = JOB_STATUS_GAUGE.remove_label_values(&[engine, job_id, "cancelled"]);
// Remove per-job origin gauge variants (known origins)
let _ = JOB_FOUND_ORIGIN.remove_label_values(&[engine, job_id, "cpu"]);
let _ = JOB_FOUND_ORIGIN.remove_label_values(&[engine, job_id, "gpu-g1"]);
let _ = JOB_FOUND_ORIGIN.remove_label_values(&[engine, job_id, "gpu-g2"]);
JOB_KEYS
.lock()
.unwrap()
.remove(&(engine.to_string(), job_id.to_string()));
JOB_LAST
.lock()
.unwrap()
.remove(&(engine.to_string(), job_id.to_string()));
}
/// Explicitly remove all thread-scoped metrics for a job
pub fn remove_thread_metrics_for_job(engine: &str, job_id: &str) {
// Collect matching thread keys
let keys: Vec<ThreadKey> = {
let set = THREAD_KEYS.lock().unwrap();
set.iter()
.filter(|(e, j, _)| e == engine && j == job_id)
.cloned()
.collect()
};
for (e, j, t) in keys {
let _ = THREAD_HASHES_TOTAL.remove_label_values(&[&e, &j, &t]);
let _ = THREAD_HASH_RATE.remove_label_values(&[&e, &j, &t]);
THREAD_KEYS
.lock()
.unwrap()
.remove(&(e.clone(), j.clone(), t.clone()));
THREAD_LAST
.lock()
.unwrap()
.remove(&(e.clone(), j.clone(), t.clone()));
}
}
/// Remove the per-job hash rate series for a finished job.
pub fn remove_job_hash_rate(engine: &str, job_id: &str) {
let _ = JOB_HASH_RATE.remove_label_values(&[engine, job_id]);
}
/// Remove the per-thread hash rate series for a finished thread.
pub fn remove_thread_hash_rate(engine: &str, job_id: &str, thread_id: &str) {
let _ = THREAD_HASH_RATE.remove_label_values(&[engine, job_id, thread_id]);
}
// -------------------------------------------------------------------------------------
// GPU-specific metric helpers
// -------------------------------------------------------------------------------------
/// Set the total number of GPU devices available for mining
pub fn set_gpu_device_count(count: i64) {
GPU_DEVICE_COUNT.set(count);
}
/// Set GPU device information (call once per device during initialization)
pub fn set_gpu_device_info(
device_id: &str,
name: &str,
backend: &str,
vendor: &str,
device_type: &str,
) {
GPU_DEVICE_INFO
.with_label_values(&[device_id, name, backend, vendor, device_type])
.set(1.0);
}
/// Set GPU batch size for a specific device
pub fn set_gpu_batch_size(device_id: &str, batch_size: f64) {
GPU_BATCH_SIZE
.with_label_values(&[device_id])
.set(batch_size);
}
/// Set GPU workgroup count for a specific device
pub fn set_gpu_workgroups(device_id: &str, workgroups: f64) {
GPU_WORKGROUPS
.with_label_values(&[device_id])
.set(workgroups);
}
// -------------------------------------------------------------------------------------
// HTTP Exporter (feature: http-exporter)
// -------------------------------------------------------------------------------------
/// Start the Prometheus HTTP exporter on 0.0.0.0:`port`.
/// Start the Prometheus HTTP exporter on `0.0.0.0:port`.
///
/// Behavior:
/// - Spawns the exporter as a background task and returns immediately.
/// - Serves plaintext metrics at GET /metrics.
/// - If called multiple times, multiple servers may be created (call once).
/// Spawns the exporter as a background task and returns immediately.
/// Serves plaintext metrics at `GET /metrics`.
#[cfg(feature = "http-exporter")]
pub async fn start_http_exporter(port: u16) -> Result<()> {
// Encoder is created inside the handler to avoid capturing non-Clone state
// Use REGISTRY.gather() directly in the handler
// GET /metrics -> plaintext Prometheus format
let metrics_route = warp::path("metrics").and(warp::get()).map(|| {
let encoder = TextEncoder::new();
let metric_families = REGISTRY.gather();
let mut buffer = Vec::with_capacity(16 * 1024);
let mut buffer = Vec::with_capacity(4096);
encoder
.encode(&metric_families, &mut buffer)
.unwrap_or_default();
@@ -777,7 +316,7 @@ pub async fn start_http_exporter(port: u16) -> Result<()> {
Ok(())
}
// If the exporter feature is not enabled, expose a no-op to keep call sites simple.
/// No-op when HTTP exporter feature is disabled.
#[cfg(not(feature = "http-exporter"))]
pub async fn start_http_exporter(_port: u16) -> Result<()> {
log::warn!(
+2 -4
View File
@@ -5,12 +5,10 @@ edition.workspace = true
publish = false
description = "CLI binary to run the Quantus External Miner service"
[features]
default = []
gpu = ["miner-service/gpu"]
[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 }
-134
View File
@@ -1,134 +0,0 @@
use std::env;
use std::fs;
use std::io::{self, Read};
use std::path::{Path, PathBuf};
fn main() {
// Always re-run if version env or git state might change
println!("cargo:rerun-if-env-changed=CARGO_PKG_VERSION");
println!("cargo:rerun-if-env-changed=GITHUB_SHA");
println!("cargo:rerun-if-changed=.git/HEAD");
println!("cargo:rerun-if-changed=.git/refs");
println!("cargo:rerun-if-changed=.git/packed-refs");
let pkg_version = env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "0.0.0".to_string());
let short_sha = match env::var("GITHUB_SHA") {
Ok(s) => s.get(0..8).map(|t| t.to_string()),
Err(_) => discover_short_git_sha().ok(),
};
let version = match short_sha {
Some(sha) if !sha.is_empty() => format!("{pkg_version}+{sha}"),
_ => pkg_version.clone(),
};
// Expose to the crate as an env! binding
println!("cargo:rustc-env=MINER_VERSION={version}");
// Optional extra info for diagnostics
if let Ok(sha) = env::var("GITHUB_SHA") {
println!("cargo:rustc-env=MINER_BUILD_SHA={}", sha);
}
// Log the computed version for debugging
println!("miner-cli: computed MINER_VERSION={version}");
}
/// Attempts to determine the short git SHA (first 8 hex chars) without requiring the `git` binary.
/// Handles both normal repositories and worktrees where `.git` is a file pointing to the real gitdir.
fn discover_short_git_sha() -> io::Result<String> {
let git_dir = find_git_dir()?;
let head_path = git_dir.join("HEAD");
let head = fs::read_to_string(&head_path)?.trim().to_string();
// HEAD can be either a direct SHA or a ref: refs/heads/branch
if let Some(ref_line) = head.strip_prefix("ref: ") {
let ref_path = git_dir.join(ref_line.trim());
if ref_path.is_file() {
let mut contents = String::new();
fs::File::open(ref_path)?.read_to_string(&mut contents)?;
let sha = contents.trim();
return Ok(shorten_sha(sha));
}
// Not a loose ref; try packed-refs
let packed = git_dir.join("packed-refs");
if packed.is_file() {
let contents = fs::read_to_string(packed)?;
for line in contents.lines() {
// Skip comments and peeled lines
if line.starts_with('#') || line.starts_with('^') || line.trim().is_empty() {
continue;
}
// Format: <sha> <ref>
if let Some((sha, r)) = line.split_once(' ') {
if r.trim() == ref_line.trim() {
return Ok(shorten_sha(sha.trim()));
}
}
}
}
Err(io::Error::new(
io::ErrorKind::NotFound,
format!(
"Could not resolve ref '{}' in {:?}",
ref_line.trim(),
git_dir
),
))
} else {
// HEAD contained a SHA directly (e.g., detached HEAD)
Ok(shorten_sha(head.trim()))
}
}
/// Finds the actual .git directory, handling both:
/// - Standard repo: a ".git" directory exists, or
/// - Worktree/submodule: ".git" is a file containing "gitdir: <path>"
fn find_git_dir() -> io::Result<PathBuf> {
let mut dir = env::current_dir()?;
loop {
let dot_git = dir.join(".git");
if dot_git.is_dir() {
return Ok(dot_git);
} else if dot_git.is_file() {
// Read the pointer file
let contents = fs::read_to_string(&dot_git)?;
if let Some(p) = contents.strip_prefix("gitdir:") {
let p = p.trim();
let path = Path::new(p);
// If relative, it's relative to the location of the .git file's parent
let resolved = if path.is_absolute() {
path.to_path_buf()
} else {
dir.join(path)
};
if resolved.is_dir() {
return Ok(resolved);
} else {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!("Resolved gitdir does not exist: {}", resolved.display()),
));
}
}
}
// Ascend until root
if !dir.pop() {
break;
}
}
Err(io::Error::new(
io::ErrorKind::NotFound,
"Could not locate .git directory",
))
}
fn shorten_sha(full: &str) -> String {
let s = full.trim();
if s.len() >= 8 {
s[..8].to_string()
} else {
s.to_string()
}
}
+345 -383
View File
@@ -1,123 +1,124 @@
use clap::{Parser, Subcommand};
use engine_cpu::{EngineRange, MinerEngine};
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;
use std::time::{Duration, Instant};
#[derive(Subcommand, Debug)]
#[allow(clippy::large_enum_variant)]
enum Command {
/// Run the mining service (default behavior)
Serve {
/// Port number to listen on for the miner HTTP API
#[arg(short, long, env = "MINER_PORT", default_value_t = 9833)]
port: u16,
// CLI defaults
const DEFAULT_GPU_BATCH_SIZE: u32 = 1_000_000;
const DEFAULT_CPU_BATCH_SIZE: u64 = 10_000;
/// Number of CPU worker threads to use for mining (None = auto-detect)
#[derive(Subcommand, Debug)]
enum Command {
/// Run the mining service
Serve {
/// Address of the node to connect to
#[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>,
/// Number of GPU worker threads to use for mining (None = auto-detect)
#[arg(long = "gpu-workers", env = "MINER_GPU_WORKERS")]
gpu_workers: Option<usize>,
/// Number of GPU devices to use for mining (default: auto-detect)
#[arg(long = "gpu-devices", env = "MINER_GPU_DEVICES")]
gpu_devices: Option<usize>,
/// Optional Prometheus metrics exporter port; if omitted, metrics are disabled
#[arg(long, env = "MINER_METRICS_PORT")]
metrics_port: Option<u16>,
/// 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, value_parser = clap::value_parser!(u32).range(1..))]
gpu_batch_size: u32,
/// Enable verbose logging (shows debug info, progress details, etc.)
/// 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, value_parser = clap::value_parser!(u64).range(1..))]
cpu_batch_size: u64,
/// Port for Prometheus metrics HTTP endpoint (default: 9900)
#[arg(
long = "metrics-port",
env = "MINER_METRICS_PORT",
default_value_t = 9900
)]
metrics_port: u16,
/// GPU throttle delay in milliseconds between batches (0 = no throttle)
#[arg(
long = "gpu-throttle-ms",
env = "MINER_GPU_THROTTLE_MS",
default_value_t = 0
)]
gpu_throttle_ms: u64,
/// Allow integrated GPUs (APUs) even when discrete GPUs are available.
/// By default, integrated GPUs are skipped when a discrete GPU is present
/// to avoid resource contention and driver instability.
#[arg(long = "allow-integrated", env = "MINER_ALLOW_INTEGRATED")]
allow_integrated: bool,
/// Enable verbose logging
#[arg(short, long, env = "MINER_VERBOSE")]
verbose: bool,
/// How often to report mining progress (in milliseconds).
/// Smaller values give more frequent updates but slightly reduce performance.
#[arg(long = "progress-interval-ms", env = "MINER_PROGRESS_INTERVAL_MS")]
progress_interval_ms: Option<u64>,
/// Size of work chunks to process before reporting progress (number of hashes).
/// If omitted, uses engine-specific defaults (200K for CPU, 100M for GPU).
#[arg(long = "chunk-size", env = "MINER_CHUNK_SIZE")]
chunk_size: Option<u64>,
/// For cpu-chain-manipulator: start throttle index at this many solved blocks
/// to "pick up where we left off" after restarts.
#[arg(long = "manip-solved-blocks", env = "MINER_MANIP_SOLVED_BLOCKS")]
manip_solved_blocks: Option<u64>,
/// For cpu-chain-manipulator: base sleep per batch in nanoseconds (default 500_000 ns)
#[arg(long = "manip-base-delay-ns", env = "MINER_MANIP_BASE_DELAY_NS")]
manip_base_delay_ns: Option<u64>,
/// For cpu-chain-manipulator: number of nonce attempts between sleeps (default 10_000)
#[arg(long = "manip-step-batch", env = "MINER_MANIP_STEP_BATCH")]
manip_step_batch: Option<u64>,
/// For cpu-chain-manipulator: optional cap on solved-blocks throttle index
#[arg(long = "manip-throttle-cap", env = "MINER_MANIP_THROTTLE_CAP")]
manip_throttle_cap: Option<u64>,
/// Telemetry endpoints (repeat --telemetry-endpoint or comma-separated)
#[arg(long = "telemetry-endpoint", env = "MINER_TELEMETRY_ENDPOINTS", value_delimiter = ',', num_args = 0.., value_name = "URL")]
telemetry_endpoints: Option<Vec<String>>,
/// Enable or disable telemetry explicitly
#[arg(long = "telemetry-enabled", env = "MINER_TELEMETRY_ENABLED")]
telemetry_enabled: Option<bool>,
/// Telemetry verbosity level (0..=4 typical)
#[arg(long = "telemetry-verbosity", env = "MINER_TELEMETRY_VERBOSITY")]
telemetry_verbosity: Option<u8>,
/// Interval seconds for system.interval messages
#[arg(
long = "telemetry-interval-secs",
env = "MINER_TELEMETRY_INTERVAL_SECS"
)]
telemetry_interval_secs: Option<u64>,
/// Default association: chain name
#[arg(long = "telemetry-chain", env = "MINER_TELEMETRY_CHAIN")]
telemetry_chain: Option<String>,
/// Default association: genesis hash (hex)
#[arg(long = "telemetry-genesis", env = "MINER_TELEMETRY_GENESIS")]
telemetry_genesis: Option<String>,
/// Default association: node telemetry id
#[arg(long = "telemetry-node-id", env = "MINER_TELEMETRY_NODE_ID")]
telemetry_node_id: Option<String>,
/// Default association: node libp2p peer id
#[arg(long = "telemetry-node-peer-id", env = "MINER_TELEMETRY_NODE_PEER_ID")]
telemetry_node_peer_id: Option<String>,
/// Default association: node name
#[arg(long = "telemetry-node-name", env = "MINER_TELEMETRY_NODE_NAME")]
telemetry_node_name: Option<String>,
/// Default association: node version
#[arg(long = "telemetry-node-version", env = "MINER_TELEMETRY_NODE_VERSION")]
telemetry_node_version: Option<String>,
},
/// Run a quick benchmark of the mining engines
Benchmark {
/// Number of CPU workers to use for benchmark
#[arg(long = "cpu-workers", env = "MINER_CPU_WORKERS")]
cpu_workers: Option<usize>,
/// Number of GPU workers to use for benchmark
#[arg(long = "gpu-workers", env = "MINER_GPU_WORKERS")]
gpu_workers: Option<usize>,
/// Number of GPU devices to use for benchmark
#[arg(long = "gpu-devices", env = "MINER_GPU_DEVICES")]
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, 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, value_parser = clap::value_parser!(u64).range(1..))]
cpu_batch_size: u64,
/// Benchmark duration in seconds (default: 10)
#[arg(short, long, default_value_t = 10)]
duration: u64,
/// Enable verbose logging during benchmark
/// Allow integrated GPUs (APUs) even when discrete GPUs are available
#[arg(long = "allow-integrated", env = "MINER_ALLOW_INTEGRATED")]
allow_integrated: bool,
/// Enable verbose logging
#[arg(short, long, env = "MINER_VERBOSE")]
verbose: bool,
},
@@ -125,336 +126,320 @@ enum Command {
/// Quantus External Miner CLI
#[derive(Parser, Debug)]
#[command(author, version = option_env!("MINER_VERSION").unwrap_or(env!("CARGO_PKG_VERSION")), about, long_about = None)]
#[command(author, version, about, long_about = None)]
struct Args {
#[command(subcommand)]
command: Option<Command>,
}
#[allow(clippy::enum_variant_names)]
#[tokio::main]
async fn main() {
let args = Args::parse();
match args.command.unwrap_or(Command::Serve {
port: 9833,
cpu_workers: None,
gpu_workers: None,
metrics_port: None,
verbose: false,
progress_interval_ms: None,
chunk_size: None,
manip_solved_blocks: None,
manip_base_delay_ns: None,
manip_step_batch: None,
manip_throttle_cap: None,
telemetry_endpoints: None,
telemetry_enabled: None,
telemetry_verbosity: None,
telemetry_interval_secs: None,
telemetry_chain: None,
telemetry_genesis: None,
telemetry_node_id: None,
telemetry_node_peer_id: None,
telemetry_node_name: None,
telemetry_node_version: None,
}) {
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 \
--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 {
port,
node_addr,
auth_token,
auth_token_file,
tls_cert_sha256,
tls_cert_sha256_file,
cpu_workers,
gpu_workers,
gpu_devices,
gpu_batch_size,
cpu_batch_size,
gpu_throttle_ms,
metrics_port,
allow_integrated,
verbose,
progress_interval_ms,
chunk_size,
manip_solved_blocks,
manip_base_delay_ns,
manip_step_batch,
manip_throttle_cap,
telemetry_endpoints,
telemetry_enabled,
telemetry_verbosity,
telemetry_interval_secs,
telemetry_chain,
telemetry_genesis,
telemetry_node_id,
telemetry_node_peer_id,
telemetry_node_name,
telemetry_node_version,
} => {
run_serve_command(
port,
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
if let Err(e) = metrics::start_http_exporter(metrics_port).await {
log::error!("Failed to start metrics exporter: {e:?}");
std::process::exit(1);
}
log::info!(
"Metrics available at http://0.0.0.0:{}/metrics",
metrics_port
);
let config = ServiceConfig {
node_addr,
auth_token,
tls_cert_sha256,
cpu_workers,
gpu_workers,
metrics_port,
verbose,
progress_interval_ms,
chunk_size,
manip_solved_blocks,
manip_base_delay_ns,
manip_step_batch,
manip_throttle_cap,
telemetry_endpoints,
telemetry_enabled,
telemetry_verbosity,
telemetry_interval_secs,
telemetry_chain,
telemetry_genesis,
telemetry_node_id,
telemetry_node_peer_id,
telemetry_node_name,
telemetry_node_version,
gpu_devices,
gpu_batch_size,
cpu_batch_size,
gpu_throttle_ms,
allow_integrated,
};
if let Err(e) = run(config).await {
log::error!("Miner service terminated with error: {e:?}");
std::process::exit(1);
}
}
Command::Benchmark {
cpu_workers,
gpu_devices,
gpu_batch_size,
cpu_batch_size,
duration,
allow_integrated,
verbose,
} => {
init_logger(verbose);
run_benchmark(
cpu_workers,
gpu_devices,
gpu_batch_size,
cpu_batch_size,
duration,
allow_integrated,
)
.await;
}
Command::Benchmark {
cpu_workers,
gpu_workers,
duration,
verbose,
} => {
run_benchmark_command(cpu_workers, gpu_workers, duration, verbose).await;
}
}
}
#[allow(clippy::too_many_arguments)]
async fn run_serve_command(
port: u16,
cpu_workers: Option<usize>,
gpu_workers: Option<usize>,
metrics_port: Option<u16>,
verbose: bool,
progress_interval_ms: Option<u64>,
chunk_size: Option<u64>,
manip_solved_blocks: Option<u64>,
manip_base_delay_ns: Option<u64>,
manip_step_batch: Option<u64>,
manip_throttle_cap: Option<u64>,
telemetry_endpoints: Option<Vec<String>>,
telemetry_enabled: Option<bool>,
telemetry_verbosity: Option<u8>,
telemetry_interval_secs: Option<u64>,
telemetry_chain: Option<String>,
telemetry_genesis: Option<String>,
telemetry_node_id: Option<String>,
telemetry_node_peer_id: Option<String>,
telemetry_node_name: Option<String>,
telemetry_node_version: Option<String>,
) {
// Initialize logger early to capture startup messages.
// If RUST_LOG is not set, default to appropriate level based on verbose flag
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"
"debug,miner=debug,gpu_engine=debug,engine_cpu=debug,wgpu=warn,wgpu_core=warn,wgpu_hal=warn,naga=warn"
} else {
"info,miner=info,gpu_engine=info"
"info,miner=info,gpu_engine=info,wgpu=error,wgpu_core=error,wgpu_hal=error,naga=error"
};
std::env::set_var("RUST_LOG", log_level);
}
env_logger::init();
// Telemetry CLI passthrough to env for miner-service bootstrap
if let Some(eps) = telemetry_endpoints.as_ref() {
if !eps.is_empty() {
std::env::set_var("MINER_TELEMETRY_ENDPOINTS", eps.join(","));
}
}
if let Some(v) = telemetry_enabled {
std::env::set_var("MINER_TELEMETRY_ENABLED", if v { "1" } else { "0" });
}
if let Some(v) = telemetry_verbosity {
std::env::set_var("MINER_TELEMETRY_VERBOSITY", v.to_string());
}
if let Some(v) = telemetry_interval_secs {
std::env::set_var("MINER_TELEMETRY_INTERVAL_SECS", v.to_string());
}
if let Some(v) = telemetry_chain.as_ref() {
std::env::set_var("MINER_TELEMETRY_CHAIN", v);
}
if let Some(v) = telemetry_genesis.as_ref() {
std::env::set_var("MINER_TELEMETRY_GENESIS", v);
}
if let Some(v) = telemetry_node_id.as_ref() {
std::env::set_var("MINER_TELEMETRY_NODE_ID", v);
}
if let Some(v) = telemetry_node_peer_id.as_ref() {
std::env::set_var("MINER_TELEMETRY_NODE_PEER_ID", v);
}
if let Some(v) = telemetry_node_name.as_ref() {
std::env::set_var("MINER_TELEMETRY_NODE_NAME", v);
}
if let Some(v) = telemetry_node_version.as_ref() {
std::env::set_var("MINER_TELEMETRY_NODE_VERSION", v);
}
// Log effective configuration (concise; see ServiceConfig Display)
log::info!("Starting external miner service...");
let config = ServiceConfig {
port,
cpu_workers,
gpu_workers,
metrics_port,
progress_interval_ms,
chunk_size,
manip_solved_blocks,
manip_base_delay_ns,
manip_step_batch,
manip_throttle_cap,
};
log::info!("Effective config: {config}");
if let Err(e) = run(config).await {
log::error!("Miner service terminated with error: {e:?}");
std::process::exit(1);
}
}
async fn run_benchmark_command(
async fn run_benchmark(
cpu_workers: Option<usize>,
gpu_workers: Option<usize>,
gpu_devices: Option<usize>,
gpu_batch_size: u32,
cpu_batch_size: u64,
duration: u64,
verbose: bool,
allow_integrated: bool,
) {
// Initialize logger early to capture startup messages.
if std::env::var("RUST_LOG").is_err() {
let log_level = if verbose {
"debug,miner=debug,gpu_engine=debug,engine_cpu=debug"
} else {
"info,miner=info,gpu_engine=warn,engine_cpu=info"
};
std::env::set_var("RUST_LOG", log_level);
}
env_logger::init();
let effective_cpu_workers = cpu_workers.unwrap_or_else(num_cpus::get);
let effective_gpu_workers = gpu_workers.unwrap_or(0);
let total_workers = effective_cpu_workers + effective_gpu_workers;
// Initialize GPU engine (no throttle for benchmark)
let (gpu_engine, effective_gpu_devices) = match miner_service::resolve_gpu_configuration(
gpu_devices,
gpu_batch_size,
0,
allow_integrated,
) {
Ok((engine, count)) => (engine, count),
Err(e) => {
eprintln!("❌ ERROR: {}", e);
std::process::exit(1);
}
};
let total_workers = effective_cpu_workers + effective_gpu_devices;
println!("🚀 Quantus Miner Benchmark");
println!("==========================");
println!(
"Configuration: {} CPU workers, {} GPU workers",
effective_cpu_workers, effective_gpu_workers
"CPU Workers: {} (Available: {})",
effective_cpu_workers,
num_cpus::get()
);
println!("Duration: {} seconds", duration);
println!("Total Workers: {}", total_workers);
println!("Available CPUs: {}", num_cpus::get());
// Create engines based on configuration
let cpu_engine = if effective_cpu_workers > 0 {
Some(Arc::new(engine_cpu::FastCpuEngine::new()) as Arc<dyn MinerEngine>)
} else {
None
};
let gpu_engine = if effective_gpu_workers > 0 {
#[cfg(feature = "gpu")]
{
Some(Arc::new(engine_gpu::GpuEngine::new()) as Arc<dyn MinerEngine>)
}
#[cfg(not(feature = "gpu"))]
{
eprintln!("Error: GPU workers requested but this binary was built without GPU support");
std::process::exit(1);
}
} else {
None
};
// For benchmark, we'll use CPU engine as primary for simplicity
let engine = cpu_engine
.or(gpu_engine)
.expect("At least one engine must be available");
println!("GPU Devices: {}", effective_gpu_devices);
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 {
eprintln!("Error: No workers specified");
std::process::exit(1);
}
println!("Workers: {}", total_workers);
println!();
// Run benchmark
// Create CPU engine
let cpu_engine: Option<Arc<dyn MinerEngine>> = if effective_cpu_workers > 0 {
Some(Arc::new(engine_cpu::FastCpuEngine::new(cpu_batch_size)))
} else {
None
};
let cancel_flag = Arc::new(AtomicBool::new(false));
let benchmark_start = Instant::now();
// Create a large range that should take the full duration
let benchmark_range = EngineRange {
start: U512::from(0u64),
end: U512::from(100_000_000u64), // 100M nonces - should be plenty
};
// Random header hash for benchmark
let mut header = [0u8; 32];
rand::thread_rng().fill_bytes(&mut header);
rand::rng().fill_bytes(&mut header);
let difficulty = U512::MAX; // High difficulty - no solutions expected
let ctx = engine.prepare_context(header, difficulty);
let ref_engine = cpu_engine.as_ref().or(gpu_engine.as_ref()).unwrap();
let ctx = ref_engine.prepare_context(header, difficulty);
println!("⛏️ Starting benchmark...");
// Spawn worker threads
let mut handles = Vec::new();
let total_hashes_arc = Arc::new(std::sync::Mutex::new(0u64));
let total_hashes = Arc::new(std::sync::Mutex::new(0u64));
// Use larger ranges for GPU (1M) vs CPU (10K)
let nonces_per_worker = if effective_gpu_workers > 0 {
1_000_000u64 // 1M nonces per GPU worker
} else {
10_000u64 // 10K nonces per CPU worker
};
// 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 = engine.clone();
let (engine, nonces_per_batch) = if worker_id < effective_cpu_workers {
(cpu_engine.as_ref().unwrap().clone(), cpu_chunk)
} else {
(gpu_engine.as_ref().unwrap().clone(), gpu_chunk)
};
let ctx = ctx.clone();
let cancel_flag = cancel_flag.clone();
let total_hashes = total_hashes_arc.clone();
let cancel = cancel_flag.clone();
let hashes = total_hashes.clone();
let start = benchmark_start;
let handle = thread::spawn(move || {
let worker_range = EngineRange {
start: benchmark_range.start.saturating_add(
U512::from(worker_id as u64).saturating_mul(U512::from(nonces_per_worker)),
),
end: benchmark_range
.start
.saturating_add(
U512::from((worker_id + 1) as u64)
.saturating_mul(U512::from(nonces_per_worker)),
)
.saturating_sub(U512::from(1u64)),
};
let mut worker_hashes = 0u64;
let stride = U512::from(1_000_000_000_000u64);
let mut current = U512::from(worker_id as u64).saturating_mul(stride);
let step = U512::from(nonces_per_batch);
loop {
if cancel_flag.load(std::sync::atomic::Ordering::Relaxed) {
if cancel.load(std::sync::atomic::Ordering::Relaxed) {
break;
}
let result = engine.search_range(&ctx, worker_range.clone(), &cancel_flag);
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, &cancel_check);
match result {
engine_cpu::EngineStatus::Found { hash_count, .. }
| engine_cpu::EngineStatus::Exhausted { hash_count }
| engine_cpu::EngineStatus::Cancelled { hash_count } => {
worker_hashes += hash_count;
*total_hashes.lock().unwrap() += hash_count;
| engine_cpu::EngineStatus::Cancelled { hash_count }
| engine_cpu::EngineStatus::DeviceLost { hash_count } => {
*hashes.lock().unwrap() += hash_count;
}
engine_cpu::EngineStatus::Running { .. } => {}
}
// Check if we've exceeded the time limit
if benchmark_start.elapsed() >= Duration::from_secs(duration) {
if matches!(result, engine_cpu::EngineStatus::DeviceLost { .. }) {
break;
}
current = current.saturating_add(step);
if start.elapsed() >= Duration::from_secs(duration) {
break;
}
}
worker_hashes
engine_gpu::GpuEngine::clear_worker_resources();
});
handles.push(handle);
}
// Wait for duration or interrupt
// Progress updates
let mut last_update = Instant::now();
loop {
@@ -465,70 +450,47 @@ async fn run_benchmark_command(
break;
}
// Update progress every second
if last_update.elapsed() >= Duration::from_secs(1) {
let current_hashes = *total_hashes_arc.lock().unwrap();
let current = *total_hashes.lock().unwrap();
let elapsed = benchmark_start.elapsed().as_secs_f64();
if current_hashes > 0 {
let hash_rate = current_hashes as f64 / elapsed;
let hash_rate_str = if hash_rate >= 1_000_000.0 {
format!("{:.1}M", hash_rate / 1_000_000.0)
} else if hash_rate >= 1_000.0 {
format!("{:.1}K", hash_rate / 1_000.0)
} else {
format!("{:.0}", hash_rate)
};
println!("⏱️ {:.1}s - {} H/s", elapsed, hash_rate_str);
} else if effective_gpu_workers > 0 {
println!("⏱️ {:.1}s - processing...", elapsed);
} else {
println!("⏱️ {:.1}s - starting...", elapsed);
if current > 0 {
let rate = current as f64 / elapsed;
println!("⏱️ {:.1}s - {} H/s", elapsed, format_hash_rate(rate));
}
last_update = Instant::now();
}
}
// Wait for all threads to finish
// Wait for threads
for handle in handles {
let _ = handle.join();
}
let total_elapsed = benchmark_start.elapsed();
let final_hashes = *total_hashes_arc.lock().unwrap();
let avg_hash_rate = final_hashes as f64 / total_elapsed.as_secs_f64();
let final_hashes = *total_hashes.lock().unwrap();
let avg_rate = final_hashes as f64 / total_elapsed.as_secs_f64();
println!();
println!("📊 Benchmark Results");
println!("===================");
println!("Total time: {:.2} seconds", total_elapsed.as_secs_f64());
println!("Total time: {:.2}s", total_elapsed.as_secs_f64());
println!("Total hashes: {}", final_hashes);
let hash_rate_str = if avg_hash_rate >= 1_000_000.0 {
format!("{:.2}M H/s", avg_hash_rate / 1_000_000.0)
} else if avg_hash_rate >= 1_000.0 {
format!("{:.2}K H/s", avg_hash_rate / 1_000.0)
} else {
format!("{:.0} H/s", avg_hash_rate)
};
println!("Average hash rate: {}", hash_rate_str);
println!("Average rate: {} H/s", format_hash_rate(avg_rate));
if total_workers > 1 {
let per_worker_rate = avg_hash_rate / total_workers as f64;
let per_worker_str = if per_worker_rate >= 1_000_000.0 {
format!("{:.2}M H/s", per_worker_rate / 1_000_000.0)
} else if per_worker_rate >= 1_000.0 {
format!("{:.2}K H/s", per_worker_rate / 1_000.0)
} else {
format!("{:.0} H/s", per_worker_rate)
};
println!(
"Per-worker rate: {} (across {} workers)",
per_worker_str, total_workers
);
let per_worker = avg_rate / total_workers as f64;
println!("Per-worker: {} H/s", format_hash_rate(per_worker));
}
println!("✅ Benchmark completed successfully!");
println!("✅ Benchmark completed!");
}
fn format_hash_rate(rate: f64) -> String {
if rate >= 1_000_000.0 {
format!("{:.2}M", rate / 1_000_000.0)
} else if rate >= 1_000.0 {
format!("{:.2}K", rate / 1_000.0)
} else {
format!("{:.0}", rate)
}
}
+10 -21
View File
@@ -3,43 +3,32 @@ name = "miner-service"
version.workspace = true
edition.workspace = true
publish = false
description = "Service layer: HTTP API, job orchestration, and engine abstraction for the Quantus External Miner"
description = "QUIC-based mining service for the Quantus External Miner"
[features]
default = ["cpu", "metrics"]
# Enable CPU engine by default.
default = ["cpu"]
cpu = ["engine-cpu"]
# Enable GPU engine.
gpu = ["engine-gpu"]
# Optional metrics/observability (Prometheus endpoint).
metrics = [
"dep:metrics",
"metrics/http-exporter",
]
[dependencies]
# Workspace-shared deps
warp = { workspace = true }
tokio = { workspace = true, features = ["full"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
hex = { workspace = true }
primitive-types = { workspace = true }
log = { workspace = true }
crossbeam-channel = { workspace = true }
num_cpus = { workspace = true }
thiserror = { workspace = true }
anyhow = { workspace = true }
reqwest = { version = "0.11", features = ["json"] }
quinn = "0.10"
rustls = { version = "0.21", default-features = false, features = ["dangerous_configuration", "quic"] }
# Protocol types from the node repo (keep API compatibility)
# QUIC transport
quinn = "0.10"
quic-transport = { path = "../quic-transport" }
getrandom = "0.2"
# Protocol types from the node repo
quantus-miner-api = { workspace = true }
# Local crates
pow-core = { path = "../pow-core" }
engine-cpu = { path = "../engine-cpu", optional = true }
engine-gpu = { path = "../engine-gpu", optional = true }
metrics = { path = "../metrics", optional = true }
miner-telemetry = { path = "../miner-telemetry" }
engine-gpu = { path = "../engine-gpu" }
metrics = { path = "../metrics" }
File diff suppressed because it is too large Load Diff
+337
View File
@@ -0,0 +1,337 @@
//! QUIC client for connecting to blockchain nodes.
//!
//! This module provides a QUIC client that connects to a blockchain node
//! and handles bidirectional streaming for receiving mining jobs and
//! sending results.
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::{Duration, Instant};
use engine_cpu::MinerEngine;
use primitive_types::U512;
use quantus_miner_api::{
read_message, write_message, ApiResponseStatus, MinerMessage, MiningResult,
};
use crate::{EngineType, WorkerPool};
use pow_core::format_hashrate;
/// Connect to a node and start mining.
///
/// This function connects to the node, receives mining jobs, and sends results.
/// It automatically reconnects if the connection is lost.
///
/// 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,
gpu_devices: usize,
) -> anyhow::Result<()> {
// Create persistent worker pool once - it lives for the entire miner lifetime
let worker_pool = WorkerPool::new(cpu_engine, gpu_engine, cpu_workers, gpu_devices);
let mut reconnect_delay = Duration::from_secs(1);
const MAX_RECONNECT_DELAY: Duration = Duration::from_secs(30);
loop {
log::info!("⛏️ Connecting to node at {}...", node_addr);
match establish_connection(node_addr, auth_token, tls_cert_sha256).await {
Ok((connection, send, recv)) => {
log::info!("⛏️ Connected to node at {}", node_addr);
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);
}
// 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);
}
}
log::info!("⛏️ Reconnecting in {:?}...", reconnect_delay);
tokio::time::sleep(reconnect_delay).await;
reconnect_delay = (reconnect_delay * 2).min(MAX_RECONNECT_DELAY);
}
}
/// 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 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.
async fn send_message_checked(
connection: &quinn::Connection,
send: &mut quinn::SendStream,
msg: &MinerMessage,
) -> anyhow::Result<()> {
tokio::select! {
biased;
reason = connection.closed() => {
Err(anyhow::anyhow!("Connection closed: {}", reason))
}
result = write_message(send, msg) => {
result.map_err(|e| anyhow::anyhow!("Failed to send message: {}", e))
}
}
}
/// 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;
// Set static metrics once per connection
metrics::set_effective_cpus(num_cpus::get() as i64);
metrics::set_workers(worker_pool.worker_count() as i64);
metrics::set_cpu_workers(worker_pool.cpu_worker_count() as i64);
metrics::set_gpu_devices(worker_pool.gpu_worker_count() as i64);
metrics::reset_hash_tracker();
// Current job state
// - node_job_id: The string ID from the node (e.g., "27") - used in protocol messages
// - internal_job_id: Our internal numeric ID from WorkerPool - used to detect stale results
let mut node_job_id: Option<String> = None;
let mut internal_job_id: u64 = 0;
let mut job_start_time: Option<Instant> = None;
let mut cpu_hashes: u64 = 0;
let mut gpu_hashes: u64 = 0;
let mut result_sent_for_current_job = false;
log::info!("⛏️ Waiting for mining jobs from node...");
loop {
// Poll for worker results (non-blocking via spawn_blocking)
let poll_result = if node_job_id.is_some() && !result_sent_for_current_job {
let rx = worker_pool.result_receiver().clone();
tokio::task::spawn_blocking(move || rx.recv_timeout(Duration::from_millis(10)))
.await
.unwrap_or(Err(RecvTimeoutError::Disconnected))
} else {
Err(RecvTimeoutError::Timeout)
};
// Handle worker result if any
if let Ok(worker_result) = poll_result {
// Track hashes by engine type (always, even for stale results)
match worker_result.engine_type {
EngineType::Cpu => {
cpu_hashes += worker_result.hash_count;
metrics::record_cpu_hashes(worker_result.hash_count);
}
EngineType::Gpu => {
gpu_hashes += worker_result.hash_count;
metrics::record_gpu_hashes(worker_result.hash_count);
}
}
// Check if this result is for the current job (not stale)
if worker_result.job_id != internal_job_id {
log::debug!(
"⏰ Discarding stale result from worker {} (result job_id {} != current {})",
worker_result.thread_id,
worker_result.job_id,
internal_job_id
);
continue;
}
// Only send result for the FIRST solution found for THIS job
if let Some(candidate) = worker_result.candidate {
if !result_sent_for_current_job {
if let Some(ref job_id) = node_job_id {
let total_hashes = cpu_hashes + gpu_hashes;
let elapsed = job_start_time
.map(|t| t.elapsed().as_secs_f64())
.unwrap_or(0.0);
log::info!(
"⛏️ Job {job_id} completed: {total_hashes} hashes in {elapsed:.2}s ({})",
format_hashrate(total_hashes as f64 / elapsed.max(0.001))
);
// Mark as sent BEFORE sending to prevent duplicates
result_sent_for_current_job = true;
worker_pool.cancel();
metrics::set_active_jobs(0);
let result = MiningResult {
status: ApiResponseStatus::Completed,
job_id: job_id.clone(),
nonce: Some(format!("{:x}", candidate.nonce)),
work: Some(hex::encode(candidate.work)),
hash_count: total_hashes,
elapsed_time: elapsed,
miner_id: None,
};
let msg = MinerMessage::JobResult(result);
send_message_checked(&connection, &mut send, &msg).await?;
}
}
}
}
// Check for incoming messages and connection health
tokio::select! {
biased;
reason = connection.closed() => {
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;
log::info!(
"⛏️ Received job: id={}, hash=0x{}",
request.job_id,
request.mining_hash
);
// Parse header hash
let header_hash: [u8; 32] = match hex::decode(&request.mining_hash) {
Ok(bytes) if bytes.len() == 32 => bytes.try_into().unwrap(),
_ => {
log::warn!("Invalid mining_hash in request");
let result = MiningResult {
status: ApiResponseStatus::Failed,
job_id: request.job_id,
nonce: None,
work: None,
hash_count: 0,
elapsed_time: 0.0,
miner_id: None,
};
let msg = MinerMessage::JobResult(result);
send_message_checked(&connection, &mut send, &msg).await?;
continue;
}
};
// Parse difficulty
let difficulty = match U512::from_dec_str(&request.difficulty) {
Ok(d) => d,
Err(_) => {
log::warn!("Invalid difficulty in request: parse error");
let result = MiningResult {
status: ApiResponseStatus::Failed,
job_id: request.job_id,
nonce: None,
work: None,
hash_count: 0,
elapsed_time: 0.0,
miner_id: None,
};
let msg = MinerMessage::JobResult(result);
send_message_checked(&connection, &mut send, &msg).await?;
continue;
}
};
// Reject zero difficulty to prevent division-by-zero panic in JobContext::new
if difficulty.is_zero() {
log::warn!("Invalid difficulty in request: zero is not allowed");
let result = MiningResult {
status: ApiResponseStatus::Failed,
job_id: request.job_id,
nonce: None,
work: None,
hash_count: 0,
elapsed_time: 0.0,
miner_id: None,
};
let msg = MinerMessage::JobResult(result);
send_message_checked(&connection, &mut send, &msg).await?;
continue;
}
// Reset state for new job
cpu_hashes = 0;
gpu_hashes = 0;
job_start_time = Some(Instant::now());
node_job_id = Some(request.job_id.clone());
result_sent_for_current_job = false;
log::debug!("Starting job {}", request.job_id);
metrics::set_active_jobs(1);
// start_job returns the internal job ID used to detect stale results
internal_job_id = worker_pool.start_job(header_hash, difficulty);
}
Ok(MinerMessage::JobResult(_)) => {
log::warn!("Received unexpected JobResult from node");
}
Ok(MinerMessage::Ready { .. }) => {
log::warn!("Received unexpected Ready from node");
}
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
return Err(anyhow::anyhow!("Node disconnected"));
}
Err(e) => {
return Err(anyhow::anyhow!("Read error: {}", e));
}
}
}
// Short sleep to yield when no messages
_ = tokio::time::sleep(Duration::from_millis(1)) => {}
}
}
}
-1
View File
@@ -21,5 +21,4 @@ serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["full"] }
tokio-tungstenite = { version = "0.23", features = ["rustls-tls-webpki-roots"] }
url = "2"
uuid = { version = "1", features = ["fast-rng", "v4"] }
+12 -19
View File
@@ -106,33 +106,28 @@ impl TelemetryHandle {
}
/// Emit a fully custom payload (must include `"msg"`).
pub async fn emit_payload(&self, payload: Value, verbosity: Option<u8>) {
///
/// This is intentionally lossy: if the internal channel is full, the message is dropped
/// silently to avoid blocking mining paths or causing unbounded memory growth.
pub fn emit_payload(&self, payload: Value, verbosity: Option<u8>) {
if self.senders.is_empty() {
return;
}
let msg = EnvelopeMsg { payload, verbosity };
for tx in self.senders.iter() {
// Drop on full; do not block mining paths. This is intentionally lossy under backpressure.
if tx.try_send(msg.clone()).is_err() {
// Best-effort: if try_send failed due to full buffer, attempt bounded send with small timeout.
// Avoid blocking indefinitely.
let tx = tx.clone();
let msg = msg.clone();
tokio::spawn(async move {
let _ = tx.send(msg).await;
});
}
// Drop on full; do not block mining paths.
let _ = tx.try_send(msg.clone());
}
}
/// Emit a `system.connected` message using configuration defaults and an optional link.
pub async fn emit_system_connected(&self, link: Option<&TelemetryNodeLink>) {
pub fn emit_system_connected(&self, link: Option<&TelemetryNodeLink>) {
let payload = build_system_connected_payload(&self.config, link);
self.emit_payload(payload, Some(self.verbosity)).await;
self.emit_payload(payload, Some(self.verbosity));
}
/// Emit a `system.interval` message with lightweight health stats and an optional link hint.
pub async fn emit_system_interval(&self, interval: &SystemInterval, link_hint: Option<&str>) {
pub fn emit_system_interval(&self, interval: &SystemInterval, link_hint: Option<&str>) {
let mut payload = json!({
"msg": "system.interval",
"uptime_ms": interval.uptime_ms,
@@ -169,7 +164,7 @@ impl TelemetryHandle {
.insert("linked_node_hint".to_string(), json!(hint));
}
self.emit_payload(payload, Some(self.verbosity)).await;
self.emit_payload(payload, Some(self.verbosity));
}
}
@@ -463,9 +458,7 @@ mod tests {
let cfg = TelemetryConfig::default();
let handle = start(cfg);
assert!(!handle.is_enabled());
handle
.emit_system_interval(&SystemInterval::default(), None)
.await;
handle.emit_system_connected(None).await;
handle.emit_system_interval(&SystemInterval::default(), None);
handle.emit_system_connected(None);
}
}
+32
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" }
+219
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)
}
+250
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}");
}
+104
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());
}
}
+527
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,
}
}
}
+192
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);
}
}
}
}
}
-6
View File
@@ -18,7 +18,6 @@ default = ["baseline", "std"]
# std feature wires through to dependencies that have separate std/no_std builds.
std = [
"primitive-types/std",
"qp-poseidon-core/std",
]
# Baseline/reference implementation using BigUint-backed modular exponentiation.
@@ -34,14 +33,9 @@ simd-poseidon2 = []
gpu-interop = []
[dependencies]
log = { workspace = true }
primitive-types = { workspace = true }
num-bigint = { workspace = true }
num-traits = { workspace = true }
qp-poseidon-core = { workspace = true }
qpow-math = { workspace = true }
thiserror = { workspace = true }
anyhow = { workspace = true }
[dev-dependencies]
hex = { workspace = true }
+99 -3
View File
@@ -1,8 +1,56 @@
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};
/// Job context for Bitcoin-style PoW mining with double Poseidon2 hashing
/// 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() {
return "0".to_string();
}
let s = format!("{}", n);
let len = s.len();
if len <= 12 {
s
} else {
format!("{}e{}", &s[..4], len - 1)
}
}
/// Format a hash rate with appropriate units (H/s, KH/s, MH/s, GH/s).
pub fn format_hashrate(hashes_per_sec: f64) -> String {
if hashes_per_sec >= 1_000_000_000.0 {
format!("{:.2} GH/s", hashes_per_sec / 1_000_000_000.0)
} else if hashes_per_sec >= 1_000_000.0 {
format!("{:.2} MH/s", hashes_per_sec / 1_000_000.0)
} else if hashes_per_sec >= 1_000.0 {
format!("{:.2} KH/s", hashes_per_sec / 1_000.0)
} else {
format!("{:.2} H/s", hashes_per_sec)
}
}
/// Job context for PoW mining with Poseidon2 hash_squeeze_twice
#[derive(Debug, Clone)]
pub struct JobContext {
pub header: [u8; 32],
@@ -11,7 +59,12 @@ pub struct JobContext {
}
impl JobContext {
/// Build a new context from header and difficulty
/// Build a new context from header and difficulty.
///
/// # Panics
///
/// Panics if `difficulty` is zero (division by zero in target calculation).
/// Callers must validate that difficulty is non-zero before calling this function.
pub fn new(header: [u8; 32], difficulty: U512) -> Self {
// In Bitcoin-style PoW, target = max_target / difficulty
let max_target = U512::MAX;
@@ -35,7 +88,7 @@ pub fn step_nonce(nonce: U512) -> U512 {
nonce.saturating_add(U512::from(1u64))
}
/// Compute hash for the current nonce using Bitcoin-style double Poseidon2
/// Compute hash for the current nonce using Poseidon2 hash_squeeze_twice
pub fn hash_from_nonce(ctx: &JobContext, nonce: U512) -> U512 {
let nonce_bytes = nonce.to_big_endian();
qpow_math::get_nonce_hash(ctx.header, nonce_bytes)
@@ -189,4 +242,47 @@ mod tests {
// With impossible difficulty, should not find solution
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() {
// Zero difficulty causes division by zero in target calculation.
// Callers MUST validate difficulty > 0 before calling JobContext::new.
let header = [7u8; 32];
let zero_difficulty = U512::zero();
let _ctx = JobContext::new(header, zero_difficulty);
}
}
+15
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"] }
+218
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}");
}
}
+13
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 }
+101
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. 5–20k 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);
}
}
@@ -14,8 +14,8 @@
#
# Notes:
# - CPUAffinity must be a subset of the service’s 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 8‑way logical CPU system (IDs 0‑7).
@@ -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
@@ -15,11 +15,11 @@
# - CPUAffinity MUST be a subset of the service’s 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).
+19 -7
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
+73 -36
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
+1 -1
View File
@@ -1,4 +1,4 @@
[toolchain]
channel = "stable"
channel = "1.93.0"
components = ["clippy", "rustfmt"]
profile = "minimal"
-207
View File
@@ -1,207 +0,0 @@
use primitive_types::U512;
use quantus_miner::*;
use resonance_miner_api::*;
use warp::test::request;
use warp::Filter; // Import shared API types
#[tokio::test]
async fn test_mine_endpoint() {
let state = MiningState::new();
let state_clone = state.clone();
let state_filter = warp::any().map(move || state_clone.clone());
let mine_route = warp::post()
.and(warp::path("mine"))
.and(warp::body::json())
.and(state_filter.clone())
.and_then(handle_mine_request);
// Test valid request
let valid_request = MiningRequest {
job_id: "test".to_string(),
mining_hash: "a".repeat(64),
distance_threshold: "1000".to_string(),
nonce_start: "0".repeat(128),
nonce_end: "1".repeat(128),
};
let resp = request()
.method("POST")
.path("/mine")
.json(&valid_request)
.reply(&mine_route)
.await;
assert_eq!(resp.status(), 200);
let body: MiningResponse = serde_json::from_slice(resp.body()).unwrap();
assert_eq!(body.status, ApiResponseStatus::Accepted);
assert_eq!(body.job_id, "test");
// Test duplicate job ID
let resp = request()
.method("POST")
.path("/mine")
.json(&valid_request)
.reply(&mine_route)
.await;
assert_eq!(resp.status(), 409);
let body: MiningResponse = serde_json::from_slice(resp.body()).unwrap();
assert_eq!(body.status, ApiResponseStatus::Error);
assert!(body.message.is_some());
assert!(body.message.unwrap().contains("Job already exists"));
// Test invalid request
let invalid_request = MiningRequest {
job_id: "".to_string(), // Empty job ID
mining_hash: "a".repeat(64),
distance_threshold: "1000".to_string(),
nonce_start: "0".repeat(128),
nonce_end: "1".repeat(128),
};
let resp = request()
.method("POST")
.path("/mine")
.json(&invalid_request)
.reply(&mine_route)
.await;
assert_eq!(resp.status(), 400);
}
#[tokio::test]
async fn test_result_endpoint() {
let state = MiningState::new();
let state_clone = state.clone();
let state_filter = warp::any().map(move || state_clone.clone());
// First create a job
let job = MiningJob::new([0; 32], U512::from(1000), U512::from(0), U512::from(1000));
state.add_job("test".to_string(), job).await.unwrap();
let result_route = warp::get()
.and(warp::path("result"))
.and(warp::path::param())
.and(state_filter.clone())
.and_then(handle_result_request);
// Test existing job
let resp = request()
.method("GET")
.path("/result/test")
.reply(&result_route)
.await;
assert_eq!(resp.status(), 200);
let body: MiningResult = serde_json::from_slice(resp.body()).unwrap();
assert_eq!(body.status, ApiResponseStatus::Running);
assert_eq!(body.job_id, "test");
// Test non-existent job
let resp = request()
.method("GET")
.path("/result/nonexistent")
.reply(&result_route)
.await;
assert_eq!(resp.status(), 404);
let body: MiningResult = serde_json::from_slice(resp.body()).unwrap();
assert_eq!(body.status, ApiResponseStatus::NotFound);
}
#[tokio::test]
async fn test_cancel_endpoint() {
let state = MiningState::new();
let state_clone = state.clone();
let state_filter = warp::any().map(move || state_clone.clone());
// First create a job
let job = MiningJob::new([0; 32], U512::from(1000), U512::from(0), U512::from(1000));
state.add_job("test".to_string(), job).await.unwrap();
let cancel_route = warp::post()
.and(warp::path("cancel"))
.and(warp::path::param())
.and(state_filter.clone())
.and_then(handle_cancel_request);
// Test cancel existing job
let resp = request()
.method("POST")
.path("/cancel/test")
.reply(&cancel_route)
.await;
assert_eq!(resp.status(), 200);
let body: MiningResponse = serde_json::from_slice(resp.body()).unwrap();
assert_eq!(body.status, ApiResponseStatus::Cancelled);
assert_eq!(body.job_id, "test");
// Test cancel non-existent job
let resp = request()
.method("POST")
.path("/cancel/nonexistent")
.reply(&cancel_route)
.await;
assert_eq!(resp.status(), 404);
let body: MiningResponse = serde_json::from_slice(resp.body()).unwrap();
assert_eq!(body.status, ApiResponseStatus::NotFound);
}
#[tokio::test]
async fn test_concurrent_access() {
let state = MiningState::new();
let state_clone = state.clone();
let state_filter = warp::any().map(move || state_clone.clone());
// Create multiple jobs concurrently
let mut handles = vec![];
for i in 0..10 {
let state = state.clone();
let handle = tokio::spawn(async move {
let job = MiningJob::new([0; 32], U512::from(1000), U512::from(0), U512::from(1000));
state.add_job(format!("test{}", i), job).await
});
handles.push(handle);
}
// Wait for all jobs to be created
for handle in handles {
assert!(handle.await.unwrap().is_ok());
}
// Verify all jobs exist
for i in 0..10 {
assert!(state.get_job(&format!("test{}", i)).await.is_some());
}
// Test concurrent result checks
let result_route = warp::get()
.and(warp::path("result"))
.and(warp::path::param())
.and(state_filter.clone())
.and_then(handle_result_request);
let mut result_handles = vec![];
for i in 0..10 {
let route = result_route.clone();
let handle = tokio::spawn(async move {
request()
.method("GET")
.path(&format!("/result/test{}", i))
.reply(&route)
.await
});
result_handles.push(handle);
}
// Verify all result checks succeed
for handle in result_handles {
let resp = handle.await.unwrap();
assert_eq!(resp.status(), 200);
let body: MiningResult = serde_json::from_slice(resp.body()).unwrap();
assert_eq!(body.status, ApiResponseStatus::Running);
}
}
-310
View File
@@ -1,310 +0,0 @@
// Verifying that our API spec is in sync with the implementation
// Manually generated from the OpenAPI spec but it should still flag when we get out of sync and need to update.
use primitive_types::U512;
use quantus_miner::*;
use resonance_miner_api::*; // Use the shared API types
use serde_json::json;
use warp::test::request;
use warp::Filter; // For sending custom/invalid JSON
// Helper function to setup the routes with a fresh state for each test
fn setup_routes() -> (
MiningState,
impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone,
) {
let state = MiningState::new();
let state_clone = state.clone();
let state_filter = warp::any().map(move || state_clone.clone());
let mine_route = warp::post()
.and(warp::path("mine"))
.and(warp::body::json::<MiningRequest>()) // Expect valid MiningRequest
.and(state_filter.clone())
.and_then(handle_mine_request);
let result_route = warp::get()
.and(warp::path("result"))
.and(warp::path::param::<String>())
.and(state_filter.clone())
.and_then(handle_result_request);
let cancel_route = warp::post()
.and(warp::path("cancel"))
.and(warp::path::param::<String>())
.and(state_filter.clone())
.and_then(handle_cancel_request);
let routes = mine_route.or(result_route).or(cancel_route);
(state, routes)
}
// --- /mine Endpoint Tests ---
#[tokio::test]
async fn spec_mine_valid_request() {
let (_state, routes) = setup_routes();
let valid_req = MiningRequest {
job_id: "job-valid-1".to_string(),
mining_hash: "a".repeat(64),
distance_threshold: "12345".to_string(),
nonce_start: "0".repeat(128),
nonce_end: "f".repeat(128),
};
let resp = request()
.method("POST")
.path("/mine")
.json(&valid_req)
.reply(&routes)
.await;
// Spec: 200 OK, response matches MiningResponseAccepted
assert_eq!(
resp.status(),
200,
"Spec requires 200 OK for valid /mine request"
);
let body: MiningResponse = serde_json::from_slice(resp.body())
.expect("Valid response should deserialize to MiningResponse");
assert_eq!(
body.status,
ApiResponseStatus::Accepted,
"Spec requires status 'accepted'"
);
assert_eq!(body.job_id, valid_req.job_id);
assert!(
body.message.is_none(),
"Spec shows no message field for success"
);
}
#[tokio::test]
async fn spec_mine_duplicate_job_id() {
let (state, routes) = setup_routes();
let valid_req = MiningRequest {
job_id: "job-duplicate-1".to_string(),
mining_hash: "b".repeat(64),
distance_threshold: "54321".to_string(),
nonce_start: "1".repeat(128),
nonce_end: "e".repeat(128),
};
// Add the job first
let job = MiningJob::new(
hex::decode(valid_req.mining_hash.clone())
.unwrap()
.try_into()
.unwrap(),
U512::from_dec_str(&valid_req.distance_threshold).unwrap(),
U512::from_str_radix(&valid_req.nonce_start, 16).unwrap(),
U512::from_str_radix(&valid_req.nonce_end, 16).unwrap(),
);
state.add_job(valid_req.job_id.clone(), job).await.unwrap();
// Try to submit again
let resp = request()
.method("POST")
.path("/mine")
.json(&valid_req)
.reply(&routes)
.await;
// Spec: 409 Conflict, response matches MiningResponseError
assert_eq!(
resp.status(),
409,
"Spec requires 409 Conflict for duplicate job ID"
);
let body: MiningResponse = serde_json::from_slice(resp.body())
.expect("Valid error response should deserialize to MiningResponse");
assert_eq!(
body.status,
ApiResponseStatus::Error,
"Spec requires status 'error'"
);
assert!(
body.message.is_some(),
"Spec requires message field for error"
);
assert!(body.message.unwrap().contains("Job already exists"));
assert_eq!(body.job_id, valid_req.job_id);
}
// Test cases for invalid formats (hash length, nonce length, difficulty non-numeric)
// These should ideally result in 400 Bad Request
#[tokio::test]
async fn spec_mine_invalid_hash_format() {
let (_state, routes) = setup_routes();
let invalid_req = json!({
"job_id": "job-invalid-hash",
"mining_hash": "a".repeat(63), // Too short
"distance_threshold": "1000",
"nonce_start": "0".repeat(128),
"nonce_end": "f".repeat(128)
});
let resp = request()
.method("POST")
.path("/mine")
.json(&invalid_req)
.reply(&routes)
.await;
// Spec: 400 Bad Request, response matches MiningResponseError
assert_eq!(
resp.status(),
400,
"Spec requires 400 Bad Request for invalid hash format"
);
let body: MiningResponse =
serde_json::from_slice(resp.body()).expect("Error response should deserialize");
assert_eq!(body.status, ApiResponseStatus::Error);
assert!(body.message.is_some());
assert!(body
.message
.unwrap()
.contains("mining_hash must be 64 hex characters"));
}
// Add similar tests for invalid nonce_start, nonce_end, difficulty format...
#[tokio::test]
async fn spec_mine_missing_required_field() {
let (_state, routes) = setup_routes();
// Missing 'difficulty'
let invalid_req = json!({
"job_id": "job-missing-field",
"mining_hash": "c".repeat(64),
"nonce_start": "0".repeat(128),
"nonce_end": "f".repeat(128)
});
let resp = request()
.method("POST")
.path("/mine")
.json(&invalid_req)
.reply(&routes)
.await;
// Warp's default JSON body deserialization handles missing fields
// It should return 400 Bad Request based on warp::body::json()
assert_eq!(
resp.status(),
400,
"Spec requires 400 Bad Request for missing required field"
);
// The body might be a plain text error from Warp, not necessarily our JSON structure
// We can check if the body indicates a deserialization error
let body_bytes = resp.body();
let body_string = String::from_utf8_lossy(body_bytes);
assert!(
body_string.contains("Failed to deserialize") || body_string.contains("missing field"),
"Response body should indicate deserialization failure"
);
}
// --- /result/{job_id} Endpoint Tests ---
#[tokio::test]
async fn spec_result_job_running() {
let (state, routes) = setup_routes();
let job_id = "job-running-1".to_string();
// Add a job
let job = MiningJob::new([1u8; 32], U512::from(1000), U512::from(0), U512::from(1000));
state.add_job(job_id.clone(), job).await.unwrap();
let resp = request()
.method("GET")
.path(&format!("/result/{}", job_id))
.reply(&routes)
.await;
// Spec: 200 OK, response matches MiningResult with status 'running'
assert_eq!(resp.status(), 200);
let body: MiningResult =
serde_json::from_slice(resp.body()).expect("Should deserialize to MiningResult");
assert_eq!(body.status, ApiResponseStatus::Running);
assert_eq!(body.job_id, job_id);
assert!(body.nonce.is_none()); // Should be none initially
assert!(body.work.is_none()); // No work yet
// hash_count and elapsed_time should be present
// assert_eq!(body.hash_count, 0); // Initial state
// assert!(body.elapsed_time >= 0.0);
}
// Add tests for spec_result_job_completed, spec_result_job_failed (need to manipulate state)
// Need to carefully simulate a completed/failed state or run miner loop briefly
#[tokio::test]
async fn spec_result_job_not_found() {
let (_state, routes) = setup_routes();
let job_id = "job-not-found-1";
let resp = request()
.method("GET")
.path(&format!("/result/{}", job_id))
.reply(&routes)
.await;
// Spec: 404 Not Found, response matches MiningResultNotFound
assert_eq!(resp.status(), 404);
let body: MiningResult = serde_json::from_slice(resp.body())
.expect("Should deserialize to MiningResult (NotFound variant)");
assert_eq!(body.status, ApiResponseStatus::NotFound);
assert_eq!(body.job_id, job_id);
assert!(body.nonce.is_none());
assert!(body.work.is_none());
assert_eq!(body.hash_count, 0);
assert_eq!(body.elapsed_time, 0.0);
}
// --- /cancel/{job_id} Endpoint Tests ---
#[tokio::test]
async fn spec_cancel_existing_job() {
let (state, routes) = setup_routes();
let job_id = "job-cancel-1".to_string();
let job = MiningJob::new([2u8; 32], U512::from(1000), U512::from(0), U512::from(1000));
state.add_job(job_id.clone(), job).await.unwrap();
let resp = request()
.method("POST")
.path(&format!("/cancel/{}", job_id))
.reply(&routes)
.await;
// Spec: 200 OK, response matches MiningResponseCancelled
assert_eq!(resp.status(), 200);
let body: MiningResponse =
serde_json::from_slice(resp.body()).expect("Should deserialize to MiningResponse");
assert_eq!(body.status, ApiResponseStatus::Cancelled);
assert_eq!(body.job_id, job_id);
assert!(body.message.is_none());
// Verify job is actually cancelled
let job = state.get_job(&job_id).await.unwrap();
assert_eq!(job.status, JobStatus::Cancelled);
}
#[tokio::test]
async fn spec_cancel_non_existent_job() {
let (_state, routes) = setup_routes();
let job_id = "job-cancel-not-found-1";
let resp = request()
.method("POST")
.path(&format!("/cancel/{}", job_id))
.reply(&routes)
.await;
// Spec: 404 Not Found, response matches MiningResponseNotFound
assert_eq!(resp.status(), 404);
let body: MiningResponse = serde_json::from_slice(resp.body())
.expect("Should deserialize to MiningResponse (NotFound variant)");
assert_eq!(body.status, ApiResponseStatus::NotFound);
assert_eq!(body.job_id, job_id);
assert!(body.message.is_some());
}
// Removed placeholder test