Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41ec132139 | ||
|
|
ea5e5e742e | ||
|
|
696eb51125 | ||
|
|
8d0c9f8023 | ||
|
|
37f7837f76 | ||
|
|
2c8d530de4 | ||
|
|
2b966e780a | ||
|
|
b0cfc3005e | ||
|
|
9b16df1dff | ||
|
|
7e449c0a5f | ||
|
|
22d87d4772 | ||
|
|
8aa81ec853 | ||
|
|
58615ed3fa | ||
|
|
d55b970ab6 | ||
|
|
0135e65278 | ||
|
|
2eb0341a70 | ||
|
|
0c61e3da34 | ||
|
|
71781a0a80 | ||
|
|
15edf4a806 | ||
|
|
a47db3d1a2 | ||
|
|
8359892406 | ||
|
|
d99be29a64 | ||
|
|
7296c003bb | ||
|
|
d37f1dc0e5 | ||
|
|
4d53fff54b | ||
|
|
18e8539fa3 | ||
|
|
4ab6914331 | ||
|
|
16fb76c5e8 | ||
|
|
157aa6fb5c | ||
|
|
5ce62b3e12 | ||
|
|
8de756f456 | ||
|
|
d7bf27854f | ||
|
|
f563d24241 | ||
|
|
334e852edd | ||
|
|
67cce3f3c9 | ||
|
|
9ffb5b12e2 | ||
|
|
a60d95c825 | ||
|
|
417a3efd86 | ||
|
|
443f0f9f89 | ||
|
|
0a07bb2de5 | ||
|
|
35df5a3554 | ||
|
|
bf8721f41d | ||
|
|
590ce82440 | ||
|
|
689e5f9bfd | ||
|
|
059fa8322c | ||
|
|
8295d27d25 | ||
|
|
e817c1a5e3 | ||
|
|
a8ecc6f03c | ||
|
|
91170c6aea | ||
|
|
4131b60ebb | ||
|
|
de3ffa9fb9 | ||
|
|
ad46f200c0 | ||
|
|
d14ebdf65c | ||
|
|
6834dd2dff | ||
|
|
af5564796b | ||
|
|
ffc5b8f83c | ||
|
|
3a694ecee1 | ||
|
|
b9e080e20c | ||
|
|
3705fa96e7 | ||
|
|
c3e7d70a75 | ||
|
|
0b0731b869 | ||
|
|
3b5f759fbd | ||
|
|
06933d5745 | ||
|
|
96853cad23 | ||
|
|
010eff6e71 | ||
|
|
4e7280cb0f | ||
|
|
c2773cb80d | ||
|
|
b79a9368e0 | ||
|
|
f0aa971326 | ||
|
|
23c617bda9 | ||
|
|
2a36eeb3c3 | ||
|
|
a548f74f7d | ||
|
|
97ec1350de | ||
|
|
ac87758996 |
@@ -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/
|
||||
|
||||
@@ -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,7 +84,7 @@ 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: run cpu benchmark
|
||||
run: ./target/release/quantus-miner benchmark --cpu-workers 2 --duration 5
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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-15-intel
|
||||
target: x86_64-apple-darwin
|
||||
binary_name: quantus-miner
|
||||
asset_name: quantus-miner-macos-x86_64
|
||||
features: ""
|
||||
- os: macos-15-intel
|
||||
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
|
||||
|
||||
Generated
+650
-626
File diff suppressed because it is too large
Load Diff
+8
-8
@@ -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.2"
|
||||
version = "4.0.1"
|
||||
|
||||
[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
@@ -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"]
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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,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"
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,228 @@
|
||||
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 desc = wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("../src/mining_u64.wgsl").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");
|
||||
}
|
||||
}
|
||||
@@ -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!");
|
||||
}
|
||||
|
||||
@@ -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)");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+1047
-464
File diff suppressed because it is too large
Load Diff
+115
-151
@@ -1,177 +1,141 @@
|
||||
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;
|
||||
|
||||
// Run all component tests
|
||||
if let Err(e) = tests::test_gf_from_const(&device, &queue).await {
|
||||
eprintln!("❌ gf_from_const tests failed: {}", e);
|
||||
}
|
||||
|
||||
if let Err(e) = tests::test_gf_mul(&device, &queue).await {
|
||||
eprintln!("❌ gf_mul tests failed: {}", e);
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
let (device, queue) = adapter
|
||||
.request_device(&wgpu::DeviceDescriptor::default())
|
||||
.await?;
|
||||
failures += run_suite(
|
||||
&device,
|
||||
&queue,
|
||||
include_str!("mining.wgsl"),
|
||||
"32-bit (mining.wgsl)",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
if let Err(e) = tests::test_poseidon2_permutation(&device, &queue).await {
|
||||
eprintln!("❌ Poseidon2 permutation tests failed: {}", e);
|
||||
if adapter.features().contains(wgpu::Features::SHADER_INT64) {
|
||||
let (device, queue) = adapter
|
||||
.request_device(&wgpu::DeviceDescriptor {
|
||||
label: Some("u64 Test Device"),
|
||||
required_features: wgpu::Features::SHADER_INT64,
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
failures += run_suite(
|
||||
&device,
|
||||
&queue,
|
||||
include_str!("mining_u64.wgsl"),
|
||||
"native-u64 (mining_u64.wgsl)",
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
println!("\nSHADER_INT64 not supported on this adapter; skipping mining_u64.wgsl suite");
|
||||
}
|
||||
|
||||
if let Err(e) = tests::test_bytes_to_field_elements(&device, &queue).await {
|
||||
eprintln!("❌ Bytes to field elements tests failed: {}", e);
|
||||
if failures > 0 {
|
||||
return Err(format!("{failures} test group(s) failed").into());
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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,652 @@
|
||||
// Quantus Mining Shader - native-u64 variant (requires wgpu Features::SHADER_INT64)
|
||||
// Implements Poseidon2 hash over the Goldilocks field with plonky2-style lazy reduction:
|
||||
// values live in [0, 2^64) and are only canonicalized when squeezed out.
|
||||
// Must produce byte-identical results to mining.wgsl / qp-poseidon-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);
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
®ISTRY
|
||||
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!(
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
|
||||
+453
-1824
File diff suppressed because it is too large
Load Diff
@@ -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)) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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,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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user