Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8de756f456 | ||
|
|
d7bf27854f | ||
|
|
f563d24241 | ||
|
|
334e852edd | ||
|
|
67cce3f3c9 | ||
|
|
9ffb5b12e2 | ||
|
|
a60d95c825 | ||
|
|
417a3efd86 | ||
|
|
443f0f9f89 |
Generated
+555
-614
File diff suppressed because it is too large
Load Diff
+4
-7
@@ -15,7 +15,7 @@ resolver = "2"
|
||||
edition = "2021"
|
||||
authors = ["Quantus Network"]
|
||||
description = "Quantus External Miner Workspace"
|
||||
version = "2.1.2"
|
||||
version = "3.1.0"
|
||||
|
||||
[workspace.dependencies]
|
||||
anyhow = "1"
|
||||
@@ -29,18 +29,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 }
|
||||
qp-poseidon-core = { version = "1.4.0", default-features = false }
|
||||
qpow-math = { git = "https://github.com/Quantus-Network/chain.git", tag = "v0.4.11-star-dust", package = "qpow-math", default-features = false }
|
||||
quantus-miner-api = { git = "https://github.com/Quantus-Network/chain.git", tag = "v0.4.11-star-dust" }
|
||||
rand = { version = "0.8.5", default-features = false }
|
||||
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
|
||||
|
||||
+216
-237
@@ -1,256 +1,235 @@
|
||||
# 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.
|
||||
This document defines the QUIC-based protocol for communication between the Quantus Network node and external QPoW miner services.
|
||||
|
||||
## Overview
|
||||
|
||||
The node delegates the mining task (finding a valid nonce) to 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.
|
||||
The node delegates the mining task (finding a valid nonce) to external miner services over persistent QUIC connections. The node provides the necessary parameters (header hash, difficulty) and each external miner independently searches for a valid nonce according to the QPoW rules defined in the `qpow-math` crate. Miners push results back when found.
|
||||
|
||||
### Key Benefits of QUIC
|
||||
|
||||
- **Lower latency**: Results are pushed immediately when found (no polling)
|
||||
- **Connection resilience**: Built-in connection migration and recovery
|
||||
- **Multiplexed streams**: Multiple operations on single connection
|
||||
- **Built-in TLS**: Encrypted by default
|
||||
|
||||
## Architecture
|
||||
|
||||
### Connection Model
|
||||
|
||||
```
|
||||
┌─────────────────────────────────┐
|
||||
│ Node │
|
||||
│ (QUIC Server on port 9833) │
|
||||
│ │
|
||||
┌──────────┐ │ Broadcasts: NewJob │
|
||||
│ Miner 1 │ ──connect───► │ Receives: JobResult │
|
||||
└──────────┘ │ │
|
||||
│ Supports multiple miners │
|
||||
┌──────────┐ │ First valid result wins │
|
||||
│ Miner 2 │ ──connect───► │ │
|
||||
└──────────┘ └─────────────────────────────────┘
|
||||
|
||||
┌──────────┐
|
||||
│ Miner 3 │ ──connect───►
|
||||
└──────────┘
|
||||
```
|
||||
|
||||
- **Node** acts as the QUIC server, listening on port 9833 (default)
|
||||
- **Miners** act as QUIC clients, connecting to the node
|
||||
- Single bidirectional stream per miner connection
|
||||
- Connection persists across multiple mining jobs
|
||||
- Multiple miners can connect simultaneously
|
||||
|
||||
### Multi-Miner Operation
|
||||
|
||||
When multiple miners are connected:
|
||||
1. Node broadcasts the same `NewJob` to all connected miners
|
||||
2. Each miner independently selects a random starting nonce
|
||||
3. First miner to find a valid solution sends `JobResult`
|
||||
4. Node uses the first valid result, ignores subsequent results for same job
|
||||
5. New job broadcast implicitly cancels work on all miners
|
||||
|
||||
### Message Types
|
||||
|
||||
The protocol uses only **two message types**:
|
||||
|
||||
| Direction | Message | Description |
|
||||
|-----------|---------|-------------|
|
||||
| Node → Miner | `NewJob` | Submit a mining job (implicitly cancels any previous job) |
|
||||
| Miner → Node | `JobResult` | Mining result (completed, failed, or cancelled) |
|
||||
|
||||
### Wire Format
|
||||
|
||||
Messages are length-prefixed JSON:
|
||||
|
||||
```
|
||||
┌─────────────────┬─────────────────────────────────┐
|
||||
│ Length (4 bytes)│ JSON payload (MinerMessage) │
|
||||
│ big-endian u32 │ │
|
||||
└─────────────────┴─────────────────────────────────┘
|
||||
```
|
||||
|
||||
Maximum message size: 16 MB
|
||||
|
||||
## Data Types
|
||||
|
||||
See the `resonance-miner-api` crate for the canonical Rust definitions of these structures.
|
||||
See the `quantus-miner-api` crate for the canonical Rust definitions.
|
||||
|
||||
- `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.
|
||||
### MinerMessage (Enum)
|
||||
|
||||
## Endpoints
|
||||
```rust
|
||||
pub enum MinerMessage {
|
||||
NewJob(MiningRequest),
|
||||
JobResult(MiningResult),
|
||||
}
|
||||
```
|
||||
|
||||
### 1. Submit Mining Job
|
||||
### MiningRequest
|
||||
|
||||
- **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 (...)"
|
||||
}
|
||||
```
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `job_id` | String | Unique identifier (UUID recommended) |
|
||||
| `mining_hash` | String | Header hash (64 hex chars, no 0x prefix) |
|
||||
| `distance_threshold` | String | Difficulty (U512 as decimal string) |
|
||||
|
||||
### 2. Get Job Result
|
||||
Note: Nonce range is not specified - each miner independently selects a random starting point.
|
||||
|
||||
- **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
|
||||
}
|
||||
```
|
||||
### MiningResult
|
||||
|
||||
### 3. Cancel Mining Job
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `status` | ApiResponseStatus | Result status (see below) |
|
||||
| `job_id` | String | Job identifier |
|
||||
| `nonce` | Option<String> | Winning nonce (U512 hex, no 0x prefix) |
|
||||
| `work` | Option<String> | Winning nonce as bytes (128 hex chars) |
|
||||
| `hash_count` | u64 | Number of nonces checked |
|
||||
| `elapsed_time` | f64 | Time spent mining (seconds) |
|
||||
|
||||
- **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": "..."
|
||||
}
|
||||
```
|
||||
### ApiResponseStatus (Enum)
|
||||
|
||||
| Value | Description |
|
||||
|-------|-------------|
|
||||
| `completed` | Valid nonce found |
|
||||
| `failed` | Nonce range exhausted without finding solution |
|
||||
| `cancelled` | Job was cancelled (new job received) |
|
||||
| `running` | Job still in progress (not typically sent) |
|
||||
|
||||
## Protocol Flow
|
||||
|
||||
### Normal Mining Flow
|
||||
|
||||
```
|
||||
Miner Node
|
||||
│ │
|
||||
│──── QUIC Connect ─────────────────────────►│
|
||||
│◄─── Connection Established ────────────────│
|
||||
│ │
|
||||
│◄─── NewJob { job_id: "abc", ... } ─────────│
|
||||
│ │
|
||||
│ (picks random nonce, starts mining) │
|
||||
│ │
|
||||
│──── JobResult { job_id: "abc", ... } ─────►│ (found solution!)
|
||||
│ │
|
||||
│ (node submits block, gets new work) │
|
||||
│ │
|
||||
│◄─── NewJob { job_id: "def", ... } ─────────│
|
||||
│ │
|
||||
```
|
||||
|
||||
### Job Cancellation (Implicit)
|
||||
|
||||
When a new block arrives before the miner finds a solution, the node simply sends a new `NewJob`. The miner automatically cancels the previous job:
|
||||
|
||||
```
|
||||
Miner Node
|
||||
│ │
|
||||
│◄─── NewJob { job_id: "abc", ... } ─────────│
|
||||
│ │
|
||||
│ (mining "abc") │
|
||||
│ │
|
||||
│ (new block arrives at node!) │
|
||||
│ │
|
||||
│◄─── NewJob { job_id: "def", ... } ─────────│
|
||||
│ │
|
||||
│ (cancels "abc", starts "def") │
|
||||
│ │
|
||||
│──── JobResult { job_id: "def", ... } ─────►│
|
||||
```
|
||||
|
||||
### Miner Connect During Active Job
|
||||
|
||||
When a miner connects while a job is active, it immediately receives the current job:
|
||||
|
||||
```
|
||||
Miner (new) Node
|
||||
│ │ (already mining job "abc")
|
||||
│──── QUIC Connect ─────────────────────────►│
|
||||
│◄─── Connection Established ────────────────│
|
||||
│◄─── NewJob { job_id: "abc", ... } ─────────│ (current job sent immediately)
|
||||
│ │
|
||||
│ (joins mining effort) │
|
||||
```
|
||||
|
||||
### Stale Result Handling
|
||||
|
||||
If a result arrives for an old job, the node discards it:
|
||||
|
||||
```
|
||||
Miner Node
|
||||
│ │
|
||||
│◄─── NewJob { job_id: "abc", ... } ─────────│
|
||||
│ │
|
||||
│◄─── NewJob { job_id: "def", ... } ─────────│ (almost simultaneous)
|
||||
│ │
|
||||
│──── JobResult { job_id: "abc", ... } ─────►│ (stale, node ignores)
|
||||
│ │
|
||||
│──── JobResult { job_id: "def", ... } ─────►│ (current, node uses)
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Node
|
||||
|
||||
```bash
|
||||
# Listen for external miner connections on port 9833
|
||||
quantus-node --miner-listen-port 9833
|
||||
```
|
||||
|
||||
### Miner
|
||||
|
||||
```bash
|
||||
# Connect to node
|
||||
quantus-miner serve --node-addr 127.0.0.1:9833
|
||||
```
|
||||
|
||||
## TLS Configuration
|
||||
|
||||
The node generates a self-signed TLS certificate at startup. The miner skips certificate verification by default (insecure mode). For production deployments, consider:
|
||||
|
||||
1. **Certificate pinning**: Configure the miner to accept only specific certificate fingerprints
|
||||
2. **Proper CA**: Use certificates signed by a trusted CA
|
||||
3. **Network isolation**: Run node and miner on a private network
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Connection Loss
|
||||
|
||||
The miner automatically reconnects with exponential backoff:
|
||||
- Initial delay: 1 second
|
||||
- Maximum delay: 30 seconds
|
||||
|
||||
The node continues operating with remaining connected miners.
|
||||
|
||||
### Validation Errors
|
||||
|
||||
If the miner receives an invalid `MiningRequest`, it sends a `JobResult` with status `failed`.
|
||||
|
||||
## Notes
|
||||
|
||||
- All hex values (`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.
|
||||
- All hex values should be sent **without** the `0x` prefix
|
||||
- The miner implements validation logic from `qpow_math::is_valid_nonce`
|
||||
- The node uses the `work` field from `MiningResult` to construct `QPoWSeal`
|
||||
- ALPN protocol identifier: `quantus-miner`
|
||||
- Each miner independently generates a random nonce starting point using cryptographically secure randomness
|
||||
- With a 512-bit nonce space, collision between miners is statistically impossible
|
||||
|
||||
@@ -36,7 +36,7 @@ The binary will be available at `target/release/quantus-miner`.
|
||||
|----------|---------------------|-------------|---------|
|
||||
| `--cpu-workers <N>` | `MINER_CPU_WORKERS` | Number of CPU worker threads | Auto-detect |
|
||||
| `--gpu-devices <N>` | `MINER_GPU_DEVICES` | Number of GPU devices | 0 |
|
||||
| `--port <PORT>` | `MINER_PORT` | HTTP API port | 9833 |
|
||||
| `--port <PORT>` | `MINER_PORT` | QUIC server port | 9833 |
|
||||
| `--metrics-port <PORT>` | `MINER_METRICS_PORT` | Prometheus metrics port | Disabled |
|
||||
|
||||
## GPU Mining
|
||||
@@ -88,13 +88,15 @@ RUST_LOG=debug ./target/release/quantus-miner serve --cpu-workers 2 --gpu-device
|
||||
--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 certificates)
|
||||
- **Port**: 9833 (default)
|
||||
- **Messages**: `NewJob` (from node) and `JobResult` (from miner)
|
||||
|
||||
For full protocol specification, see `EXTERNAL_MINER_PROTOCOL.md`.
|
||||
|
||||
## Docker
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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),
|
||||
@@ -25,7 +26,7 @@ fn bench_cpu_fast_engine(c: &mut Criterion) {
|
||||
let result = engine.search_range(
|
||||
black_box(&ctx),
|
||||
black_box(large_range.clone()),
|
||||
black_box(&cancel_flag),
|
||||
black_box(&cancel_check),
|
||||
);
|
||||
black_box(result)
|
||||
})
|
||||
|
||||
+85
-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 {
|
||||
@@ -69,115 +53,70 @@ pub enum EngineStatus {
|
||||
},
|
||||
}
|
||||
|
||||
/// 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 +133,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 +181,6 @@ impl MinerEngine for FastCpuEngine {
|
||||
break;
|
||||
}
|
||||
|
||||
// Advance to next nonce
|
||||
current = step_nonce(current);
|
||||
}
|
||||
|
||||
@@ -240,140 +188,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 +197,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 +224,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,26 +11,23 @@ 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" }
|
||||
p3-goldilocks = "0.3"
|
||||
p3-field = "0.3"
|
||||
|
||||
wgpu = { version = "27.0.1" } # GPU compute library
|
||||
futures = "0.3" # For async executor
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
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;
|
||||
use std::time::Duration;
|
||||
|
||||
fn bench_cpu_vs_gpu_small(c: &mut Criterion) {
|
||||
let cpu_engine = FastCpuEngine::new();
|
||||
let gpu_engine = GpuEngine::new(Duration::from_millis(3000));
|
||||
let cpu_engine = FastCpuEngine::new(10_000);
|
||||
let gpu_engine = GpuEngine::try_new(10_000_000).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 {
|
||||
@@ -32,7 +32,7 @@ fn bench_cpu_vs_gpu_small(c: &mut Criterion) {
|
||||
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)
|
||||
})
|
||||
@@ -48,7 +48,7 @@ fn bench_cpu_vs_gpu_small(c: &mut Criterion) {
|
||||
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)
|
||||
})
|
||||
@@ -58,9 +58,10 @@ fn bench_cpu_vs_gpu_small(c: &mut Criterion) {
|
||||
}
|
||||
|
||||
fn bench_cpu_vs_gpu_medium(c: &mut Criterion) {
|
||||
let cpu_engine = FastCpuEngine::new();
|
||||
let gpu_engine = GpuEngine::new(Duration::from_millis(3000));
|
||||
let cpu_engine = FastCpuEngine::new(10_000);
|
||||
let gpu_engine = GpuEngine::try_new(10_000_000).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 {
|
||||
@@ -82,7 +83,7 @@ fn bench_cpu_vs_gpu_medium(c: &mut Criterion) {
|
||||
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)
|
||||
})
|
||||
@@ -98,7 +99,7 @@ fn bench_cpu_vs_gpu_medium(c: &mut Criterion) {
|
||||
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)
|
||||
})
|
||||
@@ -108,9 +109,10 @@ fn bench_cpu_vs_gpu_medium(c: &mut Criterion) {
|
||||
}
|
||||
|
||||
fn bench_cpu_vs_gpu_large(c: &mut Criterion) {
|
||||
let cpu_engine = FastCpuEngine::new();
|
||||
let gpu_engine = GpuEngine::new(Duration::from_millis(3000));
|
||||
let cpu_engine = FastCpuEngine::new(10_000);
|
||||
let gpu_engine = GpuEngine::try_new(10_000_000).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 {
|
||||
@@ -132,7 +134,7 @@ fn bench_cpu_vs_gpu_large(c: &mut Criterion) {
|
||||
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)
|
||||
})
|
||||
@@ -148,7 +150,7 @@ fn bench_cpu_vs_gpu_large(c: &mut Criterion) {
|
||||
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)
|
||||
})
|
||||
@@ -158,9 +160,10 @@ fn bench_cpu_vs_gpu_large(c: &mut Criterion) {
|
||||
}
|
||||
|
||||
fn bench_solution_finding(c: &mut Criterion) {
|
||||
let cpu_engine = FastCpuEngine::new();
|
||||
let gpu_engine = GpuEngine::new(Duration::from_millis(3000));
|
||||
let cpu_engine = FastCpuEngine::new(10_000);
|
||||
let gpu_engine = GpuEngine::try_new(10_000_000).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 {
|
||||
@@ -182,7 +185,7 @@ fn bench_solution_finding(c: &mut Criterion) {
|
||||
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)
|
||||
})
|
||||
@@ -198,7 +201,7 @@ fn bench_solution_finding(c: &mut Criterion) {
|
||||
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)
|
||||
})
|
||||
@@ -208,9 +211,10 @@ fn bench_solution_finding(c: &mut Criterion) {
|
||||
}
|
||||
|
||||
fn bench_throughput_per_second(c: &mut Criterion) {
|
||||
let cpu_engine = FastCpuEngine::new();
|
||||
let gpu_engine = GpuEngine::new(Duration::from_millis(3000));
|
||||
let cpu_engine = FastCpuEngine::new(10_000);
|
||||
let gpu_engine = GpuEngine::try_new(10_000_000).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 {
|
||||
@@ -232,7 +236,7 @@ fn bench_throughput_per_second(c: &mut Criterion) {
|
||||
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)
|
||||
})
|
||||
@@ -248,7 +252,7 @@ fn bench_throughput_per_second(c: &mut Criterion) {
|
||||
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)
|
||||
})
|
||||
@@ -258,8 +262,9 @@ fn bench_throughput_per_second(c: &mut Criterion) {
|
||||
}
|
||||
|
||||
fn bench_gpu_batch_efficiency(c: &mut Criterion) {
|
||||
let gpu_engine = GpuEngine::new(Duration::from_millis(3000));
|
||||
let gpu_engine = GpuEngine::try_new(10_000_000).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);
|
||||
@@ -291,7 +296,7 @@ fn bench_gpu_batch_efficiency(c: &mut Criterion) {
|
||||
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)
|
||||
})
|
||||
@@ -307,7 +312,7 @@ fn bench_gpu_batch_efficiency(c: &mut Criterion) {
|
||||
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)
|
||||
})
|
||||
@@ -323,7 +328,7 @@ fn bench_gpu_batch_efficiency(c: &mut Criterion) {
|
||||
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)
|
||||
})
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
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;
|
||||
use std::time::Duration;
|
||||
|
||||
fn main() {
|
||||
// Initialize logging
|
||||
@@ -17,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(Duration::from_millis(3000));
|
||||
let gpu_engine = GpuEngine::try_new(10_000_000).expect("Failed to init GPU");
|
||||
|
||||
// Search a small range around the valid nonce
|
||||
let gpu_range = Range {
|
||||
@@ -40,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);
|
||||
|
||||
@@ -82,8 +82,8 @@ 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, cancel_check_interval]
|
||||
let dispatch_config_data: [u32; 4] = [256, 1, 256, 10000];
|
||||
let dispatch_config_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Dispatch Config Buffer"),
|
||||
size: 16,
|
||||
@@ -96,6 +96,20 @@ pub async fn test_end_to_end_mining(
|
||||
bytemuck::cast_slice(&dispatch_config_data),
|
||||
);
|
||||
|
||||
// Cancel flag buffer: 0 = running, 1 = cancel requested
|
||||
let cancel_flag_data: [u32; 1] = [0];
|
||||
let cancel_flag_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Cancel Flag Buffer"),
|
||||
size: 4,
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
queue.write_buffer(
|
||||
&cancel_flag_buffer,
|
||||
0,
|
||||
bytemuck::cast_slice(&cancel_flag_data),
|
||||
);
|
||||
|
||||
// Load Shader
|
||||
let shader_source = include_str!("mining.wgsl");
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
@@ -137,6 +151,10 @@ pub async fn test_end_to_end_mining(
|
||||
binding: 4,
|
||||
resource: dispatch_config_buffer.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 5,
|
||||
resource: cancel_flag_buffer.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -193,18 +211,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)");
|
||||
|
||||
+514
-368
File diff suppressed because it is too large
Load Diff
@@ -93,8 +93,8 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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) = tests::test_hash_squeeze_twice(&device, &queue).await {
|
||||
eprintln!("❌ Hash squeeze twice tests failed: {}", e);
|
||||
}
|
||||
|
||||
if let Err(e) = end_to_end_tests::test_end_to_end_mining(&device, &queue).await {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -194,7 +194,7 @@ const MDS_MATRIX_DIAG_12: array<array<u32, 2>, 12> = array<array<u32, 2>, 12>(
|
||||
@group(0) @binding(1) var<storage, read> header: array<u32, 8>; // 32 bytes
|
||||
@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 +676,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 +694,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,10 +782,9 @@ 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)
|
||||
@@ -873,7 +871,7 @@ fn mining_main(@builtin(global_invocation_id) global_id: vec3<u32>) {
|
||||
}
|
||||
|
||||
// Hash (Big Endian)
|
||||
let hash_be = double_hash(input);
|
||||
let hash_be = hash_squeeze_twice(input);
|
||||
|
||||
// Convert to Little Endian for difficulty check and storage
|
||||
var hash_le: array<u32, 16>;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use p3_field::PrimeField64 as P3PrimeField64;
|
||||
use p3_goldilocks::Goldilocks as P3Goldilocks;
|
||||
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 rand::{Rng, SeedableRng};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
@@ -888,13 +888,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 P3 Goldilocks to P2 (Plonky2) GoldilocksField
|
||||
fn p3_to_p2(felts: Vec<P3Goldilocks>) -> 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 = p3_to_p2(qp_poseidon_core::serialization::bytes_to_felts(&zero_input));
|
||||
vectors.push(BytesToFieldTestCase {
|
||||
input_bytes: zero_input,
|
||||
expected_felts: zero_expected,
|
||||
@@ -902,11 +906,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 = p3_to_p2(qp_poseidon_core::serialization::bytes_to_felts(&ff_input));
|
||||
vectors.push(BytesToFieldTestCase {
|
||||
input_bytes: ff_input,
|
||||
expected_felts: ff_expected,
|
||||
@@ -917,11 +917,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 = p3_to_p2(qp_poseidon_core::serialization::bytes_to_felts(&seq_input));
|
||||
vectors.push(BytesToFieldTestCase {
|
||||
input_bytes: seq_input,
|
||||
expected_felts: seq_expected,
|
||||
@@ -931,11 +927,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 = p3_to_p2(qp_poseidon_core::serialization::bytes_to_felts(&input));
|
||||
vectors.push(BytesToFieldTestCase {
|
||||
input_bytes: input,
|
||||
expected_felts: expected,
|
||||
@@ -1040,20 +1032,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,
|
||||
@@ -4344,11 +4334,11 @@ 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,
|
||||
) -> 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 +4362,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;
|
||||
@@ -4386,12 +4376,12 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
|
||||
);
|
||||
|
||||
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 }
|
||||
|
||||
+212
-865
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ description = "CLI binary to run the Quantus External Miner service"
|
||||
|
||||
[dependencies]
|
||||
miner-service = { path = "../miner-service" }
|
||||
metrics = { path = "../metrics", features = ["http-exporter"] }
|
||||
clap = { workspace = true, features = ["derive", "env"] }
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
env_logger = { workspace = true }
|
||||
|
||||
+140
-356
@@ -1,5 +1,5 @@
|
||||
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;
|
||||
@@ -8,105 +8,47 @@ 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: u64 = 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,
|
||||
|
||||
/// 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 devices to use for mining (None = auto-detect)
|
||||
/// 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)]
|
||||
gpu_batch_size: u64,
|
||||
|
||||
/// 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)]
|
||||
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,
|
||||
|
||||
/// 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>,
|
||||
|
||||
/// Target duration for GPU mining batches in milliseconds (default: 3000)
|
||||
#[arg(long = "gpu-batch-duration-ms", env = "MINER_GPU_BATCH_DURATION_MS")]
|
||||
gpu_batch_duration_ms: 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
|
||||
@@ -117,11 +59,19 @@ enum Command {
|
||||
#[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)]
|
||||
gpu_batch_size: u64,
|
||||
|
||||
/// 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)]
|
||||
cpu_batch_size: u64,
|
||||
|
||||
/// Benchmark duration in seconds (default: 10)
|
||||
#[arg(short, long, default_value_t = 10)]
|
||||
duration: u64,
|
||||
|
||||
/// Enable verbose logging during benchmark
|
||||
/// Enable verbose logging
|
||||
#[arg(short, long, env = "MINER_VERBOSE")]
|
||||
verbose: bool,
|
||||
},
|
||||
@@ -135,123 +85,76 @@ struct Args {
|
||||
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_devices: 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,
|
||||
gpu_batch_duration_ms: 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");
|
||||
std::process::exit(1);
|
||||
};
|
||||
|
||||
match command {
|
||||
Command::Serve {
|
||||
port,
|
||||
node_addr,
|
||||
cpu_workers,
|
||||
gpu_devices,
|
||||
gpu_batch_size,
|
||||
cpu_batch_size,
|
||||
metrics_port,
|
||||
verbose,
|
||||
progress_interval_ms,
|
||||
chunk_size,
|
||||
manip_solved_blocks,
|
||||
manip_base_delay_ns,
|
||||
manip_step_batch,
|
||||
manip_throttle_cap,
|
||||
gpu_batch_duration_ms,
|
||||
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);
|
||||
|
||||
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,
|
||||
cpu_workers,
|
||||
gpu_devices,
|
||||
metrics_port,
|
||||
verbose,
|
||||
progress_interval_ms,
|
||||
chunk_size,
|
||||
manip_solved_blocks,
|
||||
manip_base_delay_ns,
|
||||
manip_step_batch,
|
||||
manip_throttle_cap,
|
||||
gpu_batch_duration_ms,
|
||||
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,
|
||||
)
|
||||
.await;
|
||||
gpu_batch_size,
|
||||
cpu_batch_size,
|
||||
};
|
||||
|
||||
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,
|
||||
verbose,
|
||||
} => {
|
||||
run_benchmark_command(cpu_workers, gpu_devices, duration, verbose).await;
|
||||
init_logger(verbose);
|
||||
run_benchmark(
|
||||
cpu_workers,
|
||||
gpu_devices,
|
||||
gpu_batch_size,
|
||||
cpu_batch_size,
|
||||
duration,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn run_serve_command(
|
||||
port: u16,
|
||||
cpu_workers: Option<usize>,
|
||||
gpu_devices: 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>,
|
||||
gpu_batch_duration_ms: 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 init_logger(verbose: bool) {
|
||||
if std::env::var("RUST_LOG").is_err() {
|
||||
let log_level = if verbose {
|
||||
"debug,miner=debug,gpu_engine=debug,engine_cpu=debug"
|
||||
@@ -261,91 +164,23 @@ async fn run_serve_command(
|
||||
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_devices,
|
||||
metrics_port,
|
||||
progress_interval_ms,
|
||||
chunk_size,
|
||||
manip_solved_blocks,
|
||||
manip_base_delay_ns,
|
||||
manip_step_batch,
|
||||
manip_throttle_cap,
|
||||
gpu_batch_duration_ms,
|
||||
};
|
||||
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_devices: Option<usize>,
|
||||
gpu_batch_size: u64,
|
||||
cpu_batch_size: u64,
|
||||
duration: u64,
|
||||
verbose: 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);
|
||||
|
||||
// Initialize GPU engine and determine effective GPU devices
|
||||
// Initialize GPU engine
|
||||
let (gpu_engine, effective_gpu_devices) =
|
||||
match miner_service::resolve_gpu_configuration(gpu_devices, None) {
|
||||
match miner_service::resolve_gpu_configuration(gpu_devices, gpu_batch_size) {
|
||||
Ok((engine, count)) => (engine, count),
|
||||
Err(e) => {
|
||||
eprintln!("❌ ERROR: {}", e);
|
||||
eprintln!(" Please check your --gpu-devices setting or GPU hardware.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
@@ -354,44 +189,35 @@ async fn run_benchmark_command(
|
||||
|
||||
println!("🚀 Quantus Miner Benchmark");
|
||||
println!("==========================");
|
||||
println!("Configuration:");
|
||||
println!(
|
||||
" CPU Workers: {} (Available Cores: {})",
|
||||
"CPU Workers: {} (Available: {})",
|
||||
effective_cpu_workers,
|
||||
num_cpus::get()
|
||||
);
|
||||
println!(" GPU Devices: {}", effective_gpu_devices);
|
||||
println!("GPU Devices: {}", effective_gpu_devices);
|
||||
println!("Duration: {} seconds", duration);
|
||||
println!("Total Workers: {}", total_workers);
|
||||
|
||||
// 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
|
||||
};
|
||||
println!();
|
||||
|
||||
if total_workers == 0 {
|
||||
eprintln!("Error: No workers specified");
|
||||
std::process::exit(1);
|
||||
}
|
||||
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();
|
||||
|
||||
let benchmark_range = EngineRange {
|
||||
start: U512::from(0u64),
|
||||
end: U512::from(100_000_000u64), // 100M nonces
|
||||
};
|
||||
|
||||
// Random header hash for benchmark
|
||||
let mut header = [0u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut header);
|
||||
let difficulty = U512::MAX; // High difficulty - no solutions expected
|
||||
|
||||
// We need a context from *some* engine. They should be compatible (same job).
|
||||
// Just pick one.
|
||||
let ref_engine = cpu_engine.as_ref().or(gpu_engine.as_ref()).unwrap();
|
||||
let ctx = ref_engine.prepare_context(header, difficulty);
|
||||
|
||||
@@ -399,81 +225,62 @@ async fn run_benchmark_command(
|
||||
|
||||
// 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));
|
||||
|
||||
// Chunk sizes
|
||||
let cpu_chunk = 10_000u64;
|
||||
// GPU chunk size from 1 - 10M is good
|
||||
let gpu_chunk = 1_000_000u64;
|
||||
|
||||
for worker_id in 0..total_workers {
|
||||
// Determine type and engine
|
||||
let (engine, nonces_per_worker) = if worker_id < effective_cpu_workers {
|
||||
(
|
||||
cpu_engine.as_ref().expect("CPU engine missing").clone(),
|
||||
cpu_chunk,
|
||||
)
|
||||
let (engine, nonces_per_batch) = if worker_id < effective_cpu_workers {
|
||||
(cpu_engine.as_ref().unwrap().clone(), cpu_chunk)
|
||||
} else {
|
||||
(
|
||||
gpu_engine.as_ref().expect("GPU engine missing").clone(),
|
||||
gpu_chunk,
|
||||
)
|
||||
(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 || {
|
||||
// We'll just define a range based on worker ID and a large multiplier
|
||||
// so they don't overlap easily, though it doesn't matter for pure hashrate.
|
||||
// Use a large stride to separate workers.
|
||||
let stride = U512::from(1_000_000_000_000u64);
|
||||
let worker_start = benchmark_range
|
||||
.start
|
||||
.saturating_add(U512::from(worker_id as u64).saturating_mul(stride));
|
||||
|
||||
let worker_start = U512::from(worker_id as u64).saturating_mul(stride);
|
||||
let worker_range = EngineRange {
|
||||
start: worker_start,
|
||||
end: worker_start
|
||||
.saturating_add(U512::from(nonces_per_worker))
|
||||
.saturating_add(U512::from(nonces_per_batch))
|
||||
.saturating_sub(U512::from(1u64)),
|
||||
};
|
||||
|
||||
let mut worker_hashes = 0u64;
|
||||
|
||||
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 cancel_check = AtomicBoolCancelCheck(&cancel);
|
||||
let result = engine.search_range(&ctx, worker_range.clone(), &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;
|
||||
*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 start.elapsed() >= Duration::from_secs(duration) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
engine_gpu::GpuEngine::clear_worker_resources();
|
||||
|
||||
worker_hashes
|
||||
});
|
||||
|
||||
handles.push(handle);
|
||||
}
|
||||
|
||||
// Wait for duration or interrupt
|
||||
// Progress updates
|
||||
let mut last_update = Instant::now();
|
||||
|
||||
loop {
|
||||
@@ -484,70 +291,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_devices > 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,41 +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"]
|
||||
# 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"] }
|
||||
|
||||
# QUIC transport
|
||||
quinn = "0.10"
|
||||
rustls = { version = "0.21", default-features = false, features = ["dangerous_configuration", "quic"] }
|
||||
getrandom = "0.2"
|
||||
|
||||
# Protocol types from the node repo (keep API compatibility)
|
||||
# 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" }
|
||||
metrics = { path = "../metrics", optional = true }
|
||||
miner-telemetry = { path = "../miner-telemetry" }
|
||||
metrics = { path = "../metrics" }
|
||||
|
||||
+378
-1975
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,324 @@
|
||||
//! 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 quinn::{ClientConfig, Endpoint};
|
||||
use rustls::client::ServerCertVerified;
|
||||
|
||||
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,
|
||||
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).await {
|
||||
Ok((connection, send, recv)) => {
|
||||
log::info!("⛏️ Connected to node at {}", node_addr);
|
||||
reconnect_delay = Duration::from_secs(1);
|
||||
|
||||
if let Err(e) = handle_connection(connection, send, recv, &worker_pool).await {
|
||||
log::info!("⛏️ Connection lost: {}", e);
|
||||
// Cancel any running job when connection drops
|
||||
worker_pool.cancel();
|
||||
}
|
||||
}
|
||||
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.
|
||||
async fn establish_connection(
|
||||
addr: SocketAddr,
|
||||
) -> anyhow::Result<(quinn::Connection, quinn::SendStream, quinn::RecvStream)> {
|
||||
let mut crypto = rustls::ClientConfig::builder()
|
||||
.with_safe_defaults()
|
||||
.with_custom_certificate_verifier(Arc::new(InsecureCertVerifier))
|
||||
.with_no_client_auth();
|
||||
|
||||
crypto.alpn_protocols = vec![b"quantus-miner".to_vec()];
|
||||
|
||||
let mut client_config = ClientConfig::new(Arc::new(crypto));
|
||||
|
||||
let mut transport_config = quinn::TransportConfig::default();
|
||||
transport_config.keep_alive_interval(Some(Duration::from_secs(5)));
|
||||
transport_config.max_idle_timeout(Some(Duration::from_secs(15).try_into().unwrap()));
|
||||
client_config.transport_config(Arc::new(transport_config));
|
||||
|
||||
let mut endpoint = Endpoint::client("0.0.0.0:0".parse().unwrap())?;
|
||||
endpoint.set_default_client_config(client_config);
|
||||
|
||||
let connection = endpoint.connect(addr, "localhost")?.await?;
|
||||
log::info!("⛏️ QUIC connection established to {}", addr);
|
||||
|
||||
log::info!("⛏️ Opening bidirectional stream to node...");
|
||||
let (mut send, recv) = connection.open_bi().await?;
|
||||
|
||||
// Send Ready message to establish the stream
|
||||
write_message(&mut send, &MinerMessage::Ready).await?;
|
||||
log::info!("⛏️ Bidirectional stream established");
|
||||
|
||||
Ok((connection, send, recv))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
async fn handle_connection(
|
||||
connection: quinn::Connection,
|
||||
mut send: quinn::SendStream,
|
||||
mut recv: quinn::RecvStream,
|
||||
worker_pool: &WorkerPool,
|
||||
) -> 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() => {
|
||||
return Err(anyhow::anyhow!("Connection closed: {}", reason));
|
||||
}
|
||||
|
||||
msg_result = read_message(&mut recv) => {
|
||||
match msg_result {
|
||||
Ok(MinerMessage::NewJob(request)) => {
|
||||
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.distance_threshold) {
|
||||
Ok(d) => d,
|
||||
Err(_) => {
|
||||
log::warn!("Invalid difficulty 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;
|
||||
}
|
||||
};
|
||||
|
||||
// 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)) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Certificate verifier that accepts any certificate (for self-signed certs).
|
||||
struct InsecureCertVerifier;
|
||||
|
||||
impl rustls::client::ServerCertVerifier for InsecureCertVerifier {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
_end_entity: &rustls::Certificate,
|
||||
_intermediates: &[rustls::Certificate],
|
||||
_server_name: &rustls::ServerName,
|
||||
_scts: &mut dyn Iterator<Item = &[u8]>,
|
||||
_ocsp_response: &[u8],
|
||||
_now: std::time::SystemTime,
|
||||
) -> Result<ServerCertVerified, rustls::Error> {
|
||||
Ok(ServerCertVerified::assertion())
|
||||
}
|
||||
}
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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,8 @@ 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 }
|
||||
|
||||
@@ -2,7 +2,34 @@ use primitive_types::U512;
|
||||
|
||||
pub use qpow_math::{get_nonce_hash, is_valid_nonce, mine_range};
|
||||
|
||||
/// Job context for Bitcoin-style PoW mining with double Poseidon2 hashing
|
||||
/// 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],
|
||||
@@ -35,7 +62,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)
|
||||
|
||||
Reference in New Issue
Block a user