From 3ce89d6d9a1ab828e92cbd1dd52a808fcf744946 Mon Sep 17 00:00:00 2001 From: illuzen Date: Mon, 26 Jan 2026 18:08:04 +0800 Subject: [PATCH 01/13] quic implementation --- Cargo.lock | 242 +++++++++-------- EXTERNAL_MINER_PROTOCOL.md | 428 +++++++++++++----------------- miner-api/Cargo.toml | 2 + miner-api/src/lib.rs | 56 ++++ node/Cargo.toml | 3 +- node/src/cli.rs | 6 +- node/src/command.rs | 2 +- node/src/external_miner_client.rs | 358 ++++++++++++++++++------- node/src/service.rs | 193 ++++++++------ 9 files changed, 758 insertions(+), 532 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a5147e1b..f1bbe038 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2953,15 +2953,6 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - [[package]] name = "enum-as-inner" version = "0.6.1" @@ -3842,7 +3833,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f2f12607f92c69b12ed746fabf9ca4f5c482cba46679c1a75b874ed7c26adb" dependencies = [ "futures-io", - "rustls", + "rustls 0.23.32", "rustls-pki-types", ] @@ -4498,8 +4489,8 @@ dependencies = [ "hyper 1.7.0", "hyper-util", "log", - "rustls", - "rustls-native-certs", + "rustls 0.23.32", + "rustls-native-certs 0.8.1", "rustls-pki-types", "tokio 1.47.1", "tokio-rustls", @@ -4692,7 +4683,7 @@ dependencies = [ "netlink-proto", "netlink-sys", "rtnetlink", - "system-configuration 0.6.1", + "system-configuration", "tokio 1.47.1", "windows 0.53.0", ] @@ -5056,7 +5047,7 @@ dependencies = [ "http 1.3.1", "jsonrpsee-core", "pin-project", - "rustls", + "rustls 0.23.32", "rustls-pki-types", "rustls-platform-verifier", "soketto", @@ -5577,10 +5568,10 @@ dependencies = [ "libp2p-identity", "libp2p-tls", "parking_lot 0.12.4", - "quinn", + "quinn 0.11.9", "rand 0.8.5", "ring 0.17.14", - "rustls", + "rustls 0.23.32", "socket2 0.5.10", "thiserror 1.0.69", "tokio 1.47.1", @@ -5672,7 +5663,7 @@ dependencies = [ "libp2p-identity", "rcgen", "ring 0.17.14", - "rustls", + "rustls 0.23.32", "rustls-webpki 0.101.7", "thiserror 1.0.69", "x509-parser 0.16.0", @@ -6164,12 +6155,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - [[package]] name = "minimal-lexical" version = "0.2.1" @@ -9078,6 +9063,8 @@ name = "quantus-miner-api" version = "0.0.3" dependencies = [ "serde", + "serde_json", + "tokio 1.47.1", ] [[package]] @@ -9104,8 +9091,9 @@ dependencies = [ "qpow-math", "quantus-miner-api", "quantus-runtime", + "quinn 0.10.2", "rand 0.8.5", - "reqwest", + "rustls 0.21.12", "sc-basic-authorship", "sc-cli", "sc-client-api", @@ -9224,6 +9212,23 @@ dependencies = [ "unsigned-varint 0.8.0", ] +[[package]] +name = "quinn" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cc2c5017e4b43d5995dcea317bc46c1e09404c0a9664d2908f7f02dfe943d75" +dependencies = [ + "bytes 1.10.1", + "pin-project-lite 0.2.16", + "quinn-proto 0.10.6", + "quinn-udp 0.4.1", + "rustc-hash 1.1.0", + "rustls 0.21.12", + "thiserror 1.0.69", + "tokio 1.47.1", + "tracing", +] + [[package]] name = "quinn" version = "0.11.9" @@ -9234,10 +9239,10 @@ dependencies = [ "cfg_aliases 0.2.1", "futures-io", "pin-project-lite 0.2.16", - "quinn-proto", - "quinn-udp", + "quinn-proto 0.11.13", + "quinn-udp 0.5.14", "rustc-hash 2.1.1", - "rustls", + "rustls 0.23.32", "socket2 0.6.0", "thiserror 2.0.16", "tokio 1.47.1", @@ -9245,6 +9250,24 @@ dependencies = [ "web-time", ] +[[package]] +name = "quinn-proto" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "141bf7dfde2fbc246bfd3fe12f2455aa24b0fbd9af535d8c86c7bd1381ff2b1a" +dependencies = [ + "bytes 1.10.1", + "rand 0.8.5", + "ring 0.16.20", + "rustc-hash 1.1.0", + "rustls 0.21.12", + "rustls-native-certs 0.6.3", + "slab", + "thiserror 1.0.69", + "tinyvec", + "tracing", +] + [[package]] name = "quinn-proto" version = "0.11.13" @@ -9257,7 +9280,7 @@ dependencies = [ "rand 0.9.2", "ring 0.17.14", "rustc-hash 2.1.1", - "rustls", + "rustls 0.23.32", "rustls-pki-types", "slab", "thiserror 2.0.16", @@ -9266,6 +9289,19 @@ dependencies = [ "web-time", ] +[[package]] +name = "quinn-udp" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "055b4e778e8feb9f93c4e439f71dc2156ef13360b432b799e179a8c4cdf0b1d7" +dependencies = [ + "bytes 1.10.1", + "libc", + "socket2 0.5.10", + "tracing", + "windows-sys 0.48.0", +] + [[package]] name = "quinn-udp" version = "0.5.14" @@ -9555,42 +9591,6 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" -[[package]] -name = "reqwest" -version = "0.11.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" -dependencies = [ - "base64 0.21.7", - "bytes 1.10.1", - "encoding_rs", - "futures-core", - "futures-util", - "h2 0.3.27", - "http 0.2.12", - "http-body 0.4.6", - "hyper 0.14.32", - "ipnet", - "js-sys", - "log", - "mime", - "once_cell", - "percent-encoding", - "pin-project-lite 0.2.16", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "system-configuration 0.5.1", - "tokio 1.47.1", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "winreg", -] - [[package]] name = "resolv-conf" version = "0.7.5" @@ -9831,6 +9831,17 @@ dependencies = [ "windows-sys 0.61.0", ] +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "ring 0.17.14", + "rustls-webpki 0.101.7", + "sct", +] + [[package]] name = "rustls" version = "0.23.32" @@ -9846,6 +9857,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00" +dependencies = [ + "openssl-probe", + "rustls-pemfile", + "schannel", + "security-framework 2.11.1", +] + [[package]] name = "rustls-native-certs" version = "0.8.1" @@ -9855,7 +9878,16 @@ dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.5.0", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", ] [[package]] @@ -9879,11 +9911,11 @@ dependencies = [ "jni", "log", "once_cell", - "rustls", - "rustls-native-certs", + "rustls 0.23.32", + "rustls-native-certs 0.8.1", "rustls-platform-verifier-android", "rustls-webpki 0.103.6", - "security-framework", + "security-framework 3.5.0", "security-framework-sys", "webpki-root-certs 0.26.11", "windows-sys 0.59.0", @@ -10656,7 +10688,7 @@ dependencies = [ "parity-scale-codec", "parking_lot 0.12.4", "rand 0.8.5", - "rustls", + "rustls 0.23.32", "sc-client-api", "sc-network", "sc-network-types", @@ -11239,6 +11271,16 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring 0.17.14", + "untrusted 0.9.0", +] + [[package]] name = "sec1" version = "0.7.3" @@ -11328,6 +11370,19 @@ dependencies = [ "zeroize", ] +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.9.4", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.5.0" @@ -11456,18 +11511,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - [[package]] name = "serde_with" version = "3.14.1" @@ -13184,12 +13227,6 @@ dependencies = [ "syn 2.0.106", ] -[[package]] -name = "sync_wrapper" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" - [[package]] name = "synstructure" version = "0.12.6" @@ -13228,17 +13265,6 @@ dependencies = [ "windows 0.52.0", ] -[[package]] -name = "system-configuration" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" -dependencies = [ - "bitflags 1.3.2", - "core-foundation 0.9.4", - "system-configuration-sys 0.5.0", -] - [[package]] name = "system-configuration" version = "0.6.1" @@ -13247,17 +13273,7 @@ checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ "bitflags 2.9.4", "core-foundation 0.9.4", - "system-configuration-sys 0.6.0", -] - -[[package]] -name = "system-configuration-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" -dependencies = [ - "core-foundation-sys", - "libc", + "system-configuration-sys", ] [[package]] @@ -13565,7 +13581,7 @@ version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f63835928ca123f1bef57abbcd23bb2ba0ac9ae1235f1e65bda0d06e7786bd" dependencies = [ - "rustls", + "rustls 0.23.32", "tokio 1.47.1", ] @@ -13589,8 +13605,8 @@ checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" dependencies = [ "futures-util", "log", - "rustls", - "rustls-native-certs", + "rustls 0.23.32", + "rustls-native-certs 0.8.1", "rustls-pki-types", "tokio 1.47.1", "tokio-rustls", @@ -13914,7 +13930,7 @@ dependencies = [ "httparse", "log", "rand 0.9.2", - "rustls", + "rustls 0.23.32", "rustls-pki-types", "sha1", "thiserror 2.0.16", diff --git a/EXTERNAL_MINER_PROTOCOL.md b/EXTERNAL_MINER_PROTOCOL.md index 187d4a76..b03f4611 100644 --- a/EXTERNAL_MINER_PROTOCOL.md +++ b/EXTERNAL_MINER_PROTOCOL.md @@ -1,256 +1,210 @@ # 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 an external QPoW miner service. ## Overview -The node delegates the mining task (finding a valid nonce) to an external service. The node provides the necessary parameters (header hash, difficulty, nonce range) and the external miner searches for a valid nonce according to the QPoW rules defined in the `qpow-math` crate. The miner returns the result, including the winning nonce, when found. +The node delegates the mining task (finding a valid nonce) to an external miner service over a persistent QUIC connection. 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 pushes the result back when found. -## Data Types +### Key Benefits of QUIC -See the `resonance-miner-api` crate for the canonical Rust definitions of these structures. - -- `job_id`: String (UUID recommended) - Unique identifier for a specific mining task, generated by the node. -- `mining_hash`: String (64 hex chars, no 0x) - The header hash for which to find a nonce. -- `difficulty`: String (u64 as string) - The target difficulty for the mining job. -- `nonce_start`: String (128 hex chars, no 0x) - The starting nonce value (inclusive) for the search range. -- `nonce_end`: String (128 hex chars, no 0x) - The ending nonce value (inclusive) for the search range. -- `status`: Enum (`ApiResponseStatus`) - Indicates the state or result of an API call. -- `message`: String (optional) - Provides details for `Error` status responses. -- `nonce`: String (Hex, no 0x) - Represents the `U512` value of the current or winning nonce. -- `work`: String (128 hex chars, no 0x) - Represents the winning nonce as `[u8; 64]`. This is the value the node needs for verification. -- `hash_count`: Number (u64) - Number of nonces checked by the miner for the job. -- `elapsed_time`: Number (f64) - Time in seconds the miner spent on the job. - -## Endpoints - -### 1. Submit Mining Job - -- **Endpoint:** `POST /mine` -- **Description:** The node requests the external miner to start searching for a valid nonce. -- **Request Body (`MiningRequest`):** - ```json - { - "job_id": "...", - "mining_hash": "...", - "difficulty": "...", - "nonce_start": "...", - "nonce_end": "..." - } - ``` -- **Response Body (`MiningResponse`):** - - Success (200 OK): - ```json - { - "status": "accepted", - "job_id": "..." - } - ``` - - Error (400 Bad Request - Invalid Input / 409 Conflict - Duplicate Job ID): - ```json - { - "status": "error", - "job_id": "...", - "message": "..." // e.g., "Job already exists", "Invalid mining_hash (...)" - } - ``` - -### 2. Get Job Result - -- **Endpoint:** `GET /result/{job_id}` -- **Description:** The node polls the external miner to check the status and retrieve the result. -- **Path Parameter:** - - `job_id`: String (UUID) - The ID of the job to query. -- **Response Body (`MiningResult`):** - - Job Completed (200 OK): - ```json - { - "status": "completed", - "job_id": "...", - "nonce": "...", // U512 hex value of winning nonce - "work": "...", // [u8; 64] hex value of winning nonce - "hash_count": ..., // u64 - "elapsed_time": ... // f64 seconds - } - ``` - - Job Still Running (200 OK): - ```json - { - "status": "running", - "job_id": "...", - "nonce": "...", // Current nonce being checked (U512 hex) - "work": null, - "hash_count": ..., // u64 - "elapsed_time": ... // f64 seconds - } - ``` - - Job Failed (e.g., nonce range exhausted) (200 OK): - ```json - { - "status": "failed", - "job_id": "...", - "nonce": "...", // Final nonce checked (U512 hex) - "work": null, - "hash_count": ..., // u64 - "elapsed_time": ... // f64 seconds - } - ``` - - Job Not Found (404 Not Found): - ```json - { - "status": "not_found", - "job_id": "...", - "nonce": null, - "work": null, - "hash_count": 0, - "elapsed_time": 0.0 - } - ``` - -### 3. Cancel Mining Job - -- **Endpoint:** `POST /cancel/{job_id}` -- **Description:** The node requests the external miner to stop working on a specific job. -- **Path Parameter:** - - `job_id`: String (UUID) - The ID of the job to cancel. -- **Request Body:** (Empty) -- **Response Body (`MiningResponse`): - - Success (200 OK): - ```json - { - "status": "cancelled", - "job_id": "..." - } - ``` - - Job Not Found (404 Not Found): - ```json - { - "status": "not_found", - "job_id": "..." - } - ``` +- **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 -## Notes +## Protocol Design -- 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`. +### Connection Model -# External Miner Protocol Specification +``` +┌─────────────────────────┐ QUIC Connection ┌─────────────────────────┐ +│ Blockchain Node │◄═══════════════════════════════►│ External Miner │ +│ (QUIC Client) │ │ (QUIC Server) │ +│ │ Bidirectional Stream │ │ +│ Sends: NewJob │ ─────────────────────────────► │ Receives: NewJob │ +│ Receives: JobResult │ ◄───────────────────────────── │ Sends: JobResult │ +└─────────────────────────┘ └─────────────────────────┘ +``` -This document defines the JSON-based HTTP protocol for communication between the node and an external QPoW miner. +- **Node** acts as the QUIC client, connecting to the miner +- **Miner** acts as the QUIC server, listening on port 9833 (default) +- Single bidirectional stream per connection +- Connection persists across multiple mining jobs -## Overview +### 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 -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. +Messages are length-prefixed JSON: + +``` +┌─────────────────┬─────────────────────────────────┐ +│ Length (4 bytes)│ JSON payload (MinerMessage) │ +│ big-endian u32 │ │ +└─────────────────┴─────────────────────────────────┘ +``` + +Maximum message size: 16 MB ## 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) - } - ``` +See the `quantus-miner-api` crate for the canonical Rust definitions. + +### MinerMessage (Enum) + +```rust +pub enum MinerMessage { + NewJob(MiningRequest), + JobResult(MiningResult), +} +``` + +### MiningRequest + +| Field | Type | Description | +|-------|------|-------------| +| `job_id` | String | Unique identifier (UUID recommended) | +| `mining_hash` | String | Header hash (64 hex chars, no 0x prefix) | +| `distance_threshold` | String | Difficulty (U512 as decimal string) | +| `nonce_start` | String | Starting nonce (128 hex chars, no 0x prefix) | +| `nonce_end` | String | Ending nonce (128 hex chars, no 0x prefix) | + +### MiningResult + +| Field | Type | Description | +|-------|------|-------------| +| `status` | ApiResponseStatus | Result status (see below) | +| `job_id` | String | Job identifier | +| `nonce` | Option | Winning nonce (U512 hex, no 0x prefix) | +| `work` | Option | Winning nonce as bytes (128 hex chars) | +| `hash_count` | u64 | Number of nonces checked | +| `elapsed_time` | f64 | Time spent mining (seconds) | + +### ApiResponseStatus (Enum) + +| Value | Description | +|-------|-------------| +| `completed` | Valid nonce found | +| `failed` | Nonce range exhausted without finding solution | +| `cancelled` | Job was cancelled (new job received) | +| `running` | Job still in progress (not typically sent) | + +## Protocol Flow + +### Normal Mining Flow + +``` +Node Miner + │ │ + │──── QUIC Connect ─────────────────────────►│ + │◄─── Connection Established ────────────────│ + │ │ + │──── NewJob { job_id: "abc", ... } ────────►│ + │ │ (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: + +``` +Node Miner + │ │ + │──── NewJob { job_id: "abc", ... } ────────►│ + │ │ (mining "abc") + │ │ + │ (new block arrives at node!) │ + │ │ + │──── NewJob { job_id: "def", ... } ────────►│ + │ │ (cancels "abc", starts "def") + │ │ + │◄─── JobResult { job_id: "def", ... } ──────│ +``` + +### Stale Result Handling + +If a result arrives for an old job, the node discards it: + +``` +Node Miner + │ │ + │──── 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 +# Connect to external miner +quantus-node --external-miner-addr 127.0.0.1:9833 +``` + +### Miner + +```bash +# Start QUIC server +quantus-miner serve --quic-port 9833 +``` + +## TLS Configuration + +The miner generates a self-signed TLS certificate at startup. The node skips certificate verification by default (insecure mode). For production deployments, consider: + +1. **Certificate pinning**: Configure the node 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 node automatically reconnects with exponential backoff: +- Initial delay: 1 second +- Maximum delay: 30 seconds + +During reconnection, the node falls back to local mining if available. + +### Validation Errors + +If the miner receives an invalid `MiningRequest`, it sends a `JobResult` with status `failed`. + +## Migration from HTTP + +If you were using the previous HTTP-based protocol: + +| Old (HTTP) | New (QUIC) | +|------------|------------| +| `--external-miner-url http://...` | `--external-miner-addr host:port` | +| `--port 9833` | `--quic-port 9833` | +| `POST /mine` | `MinerMessage::NewJob` | +| `GET /result/{id}` | Results pushed automatically | +| `POST /cancel/{id}` | Implicit (send new job) | ## 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. \ No newline at end of file +- 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` diff --git a/miner-api/Cargo.toml b/miner-api/Cargo.toml index 142365a8..0f532cb7 100644 --- a/miner-api/Cargo.toml +++ b/miner-api/Cargo.toml @@ -16,3 +16,5 @@ version = "0.0.3" [dependencies] serde = { workspace = true, features = ["alloc", "derive"] } +serde_json = { workspace = true, features = ["std"] } +tokio = { workspace = true, features = ["io-util"] } diff --git a/miner-api/src/lib.rs b/miner-api/src/lib.rs index 869c869b..2b922320 100644 --- a/miner-api/src/lib.rs +++ b/miner-api/src/lib.rs @@ -1,4 +1,8 @@ use serde::{Deserialize, Serialize}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; + +/// Maximum message size (16 MB) to prevent memory exhaustion attacks. +pub const MAX_MESSAGE_SIZE: u32 = 16 * 1024 * 1024; /// Status codes returned in API responses. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] @@ -13,6 +17,58 @@ pub enum ApiResponseStatus { Error, } +/// QUIC protocol messages exchanged between node and miner. +/// +/// The protocol is simple: +/// - Node sends `NewJob` to submit a mining job (implicitly cancels any previous job) +/// - Miner sends `JobResult` when mining completes +#[derive(Serialize, Deserialize, Debug, Clone)] +pub enum MinerMessage { + /// Node → Miner: Submit a new mining job. + /// If a job is already running, it will be cancelled and replaced. + NewJob(MiningRequest), + + /// Miner → Node: Mining result (completed, failed, or cancelled). + JobResult(MiningResult), +} + +/// Write a length-prefixed JSON message to an async writer. +/// +/// Wire format: 4-byte big-endian length prefix followed by JSON payload. +pub async fn write_message( + writer: &mut W, + msg: &MinerMessage, +) -> std::io::Result<()> { + let json = serde_json::to_vec(msg) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + let len = json.len() as u32; + writer.write_all(&len.to_be_bytes()).await?; + writer.write_all(&json).await?; + Ok(()) +} + +/// Read a length-prefixed JSON message from an async reader. +/// +/// Wire format: 4-byte big-endian length prefix followed by JSON payload. +/// Returns an error if the message exceeds MAX_MESSAGE_SIZE. +pub async fn read_message(reader: &mut R) -> std::io::Result { + let mut len_buf = [0u8; 4]; + reader.read_exact(&mut len_buf).await?; + let len = u32::from_be_bytes(len_buf); + + if len > MAX_MESSAGE_SIZE { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("Message size {} exceeds maximum {}", len, MAX_MESSAGE_SIZE), + )); + } + + let mut buf = vec![0u8; len as usize]; + reader.read_exact(&mut buf).await?; + serde_json::from_slice(&buf) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e)) +} + /// Request payload sent from Node to Miner (`/mine` endpoint). #[derive(Serialize, Deserialize, Debug, Clone)] pub struct MiningRequest { diff --git a/node/Cargo.toml b/node/Cargo.toml index 271a7813..28532e75 100644 --- a/node/Cargo.toml +++ b/node/Cargo.toml @@ -38,11 +38,12 @@ qp-rusty-crystals-hdwallet.workspace = true qpow-math.workspace = true quantus-miner-api = { workspace = true } quantus-runtime.workspace = true +quinn = "0.10" rand = { workspace = true, default-features = false, features = [ "alloc", "getrandom", ] } -reqwest = { workspace = true, default-features = false, features = ["json"] } +rustls = { version = "0.21", default-features = false, features = ["dangerous_configuration", "quic"] } sc-basic-authorship.default-features = true sc-basic-authorship.workspace = true sc-cli.default-features = true diff --git a/node/src/cli.rs b/node/src/cli.rs index b9d3003a..ef17f54b 100644 --- a/node/src/cli.rs +++ b/node/src/cli.rs @@ -13,9 +13,9 @@ pub struct Cli { #[arg(long, value_name = "REWARDS_ADDRESS")] pub rewards_address: Option, - /// Specify the URL of an external QPoW miner service - #[arg(long, value_name = "EXTERNAL_MINER_URL")] - pub external_miner_url: Option, + /// Specify the address of an external QPoW miner service (e.g., "127.0.0.1:9833") + #[arg(long, value_name = "EXTERNAL_MINER_ADDR")] + pub external_miner_addr: Option, /// Enable peer sharing via RPC endpoint #[arg(long)] diff --git a/node/src/command.rs b/node/src/command.rs index 654d8506..75a511cd 100644 --- a/node/src/command.rs +++ b/node/src/command.rs @@ -481,7 +481,7 @@ pub fn run() -> sc_cli::Result<()> { ::Hash, >, >( - config, rewards_account, cli.external_miner_url.clone(), cli.enable_peer_sharing + config, rewards_account, cli.external_miner_addr.clone(), cli.enable_peer_sharing ) .map_err(sc_cli::Error::Service) }) diff --git a/node/src/external_miner_client.rs b/node/src/external_miner_client.rs index 49d7f764..9ae88557 100644 --- a/node/src/external_miner_client.rs +++ b/node/src/external_miner_client.rs @@ -1,110 +1,288 @@ -use quantus_miner_api::{ApiResponseStatus, MiningRequest, MiningResponse, MiningResult}; -/// Functions to interact with the external miner service -use reqwest::Client; +//! QUIC client for communicating with external miners. +//! +//! This module provides a persistent QUIC connection to an external miner service, +//! enabling bidirectional streaming for mining job submission and result delivery. +//! +//! # Protocol +//! +//! - Node sends `MinerMessage::NewJob` to submit a mining job (implicitly cancels any previous) +//! - Miner sends `MinerMessage::JobResult` when mining completes +//! +//! # Connection Management +//! +//! The client maintains a persistent connection and automatically reconnects on failure. + +use std::{net::SocketAddr, sync::Arc}; + +use jsonrpsee::tokio; +use quantus_miner_api::{read_message, write_message, MinerMessage, MiningRequest, MiningResult}; +use rustls::client::ServerCertVerified; use sp_core::{H256, U512}; +use tokio::sync::{mpsc, Mutex}; -// Make functions pub(crate) or pub as needed -pub(crate) async fn submit_mining_job( - client: &Client, - miner_url: &str, - job_id: &str, - mining_hash: &H256, - distance_threshold: U512, - nonce_start: U512, - nonce_end: U512, -) -> Result<(), String> { - let request = MiningRequest { - job_id: job_id.to_string(), - mining_hash: hex::encode(mining_hash.as_bytes()), - distance_threshold: distance_threshold.to_string(), - nonce_start: format!("{:0128x}", nonce_start), - nonce_end: format!("{:0128x}", nonce_end), - }; - - let response = client - .post(format!("{}/mine", miner_url)) - .json(&request) - .send() - .await - .map_err(|e| format!("Failed to send mining request: {}", e))?; +/// A QUIC client for communicating with an external miner. +/// +/// This client maintains a persistent connection and provides methods to send +/// mining jobs and receive results asynchronously. +pub struct QuicMinerClient { + /// The address of the miner to connect to. + addr: SocketAddr, + /// Channel to send commands to the connection handler task. + command_tx: mpsc::Sender, + /// Channel to receive mining results. + result_rx: Mutex>, +} - let result: MiningResponse = response - .json() - .await - .map_err(|e| format!("Failed to parse mining response: {}", e))?; +/// Commands sent to the connection handler task. +enum MinerCommand { + SendJob(MiningRequest), + Shutdown, +} - if result.status != ApiResponseStatus::Accepted { - return Err(format!("Mining job was not accepted: {:?}", result.status)); +impl QuicMinerClient { + /// Create a new QUIC miner client and connect to the miner. + /// + /// This spawns a background task that maintains the connection and handles + /// sending jobs and receiving results. + pub async fn connect(addr: SocketAddr) -> Result { + let (command_tx, command_rx) = mpsc::channel::(16); + let (result_tx, result_rx) = mpsc::channel::(16); + + // Spawn the connection handler task + let addr_clone = addr; + tokio::spawn(async move { + connection_handler(addr_clone, command_rx, result_tx).await; + }); + + log::info!("QUIC miner client created for {}", addr); + + Ok(Self { addr, command_tx, result_rx: Mutex::new(result_rx) }) } - Ok(()) + /// Send a mining job to the miner. + /// + /// This sends a `NewJob` message which implicitly cancels any previous job. + pub async fn send_job( + &self, + job_id: &str, + mining_hash: &H256, + distance_threshold: U512, + nonce_start: U512, + nonce_end: U512, + ) -> Result<(), String> { + let request = MiningRequest { + job_id: job_id.to_string(), + mining_hash: hex::encode(mining_hash.as_bytes()), + distance_threshold: distance_threshold.to_string(), + nonce_start: format!("{:0128x}", nonce_start), + nonce_end: format!("{:0128x}", nonce_end), + }; + + self.command_tx + .send(MinerCommand::SendJob(request)) + .await + .map_err(|e| format!("Failed to send job command: {}", e))?; + + Ok(()) + } + + /// Try to receive a mining result without blocking. + /// + /// Returns `Some(result)` if a result is available, `None` otherwise. + pub async fn try_recv_result(&self) -> Option { + let mut rx = self.result_rx.lock().await; + rx.try_recv().ok() + } + + /// Wait for a mining result with a timeout. + /// + /// Returns the result if one is received within the timeout, or `None` if the timeout expires. + pub async fn recv_result_timeout(&self, timeout: std::time::Duration) -> Option { + let mut rx = self.result_rx.lock().await; + tokio::time::timeout(timeout, rx.recv()).await.ok().flatten() + } + + /// Get the address of the miner this client is connected to. + pub fn addr(&self) -> SocketAddr { + self.addr + } } -pub(crate) async fn check_mining_result( - client: &Client, - miner_url: &str, - job_id: &str, -) -> Result, String> { - let response = client - .get(format!("{}/result/{}", miner_url, job_id)) - .send() - .await - .map_err(|e| format!("Failed to check mining result: {}", e))?; +impl Drop for QuicMinerClient { + fn drop(&mut self) { + // Try to send shutdown command (non-blocking) + let _ = self.command_tx.try_send(MinerCommand::Shutdown); + } +} - let result: MiningResult = response - .json() - .await - .map_err(|e| format!("Failed to parse mining result: {}", e))?; - - match result.status { - ApiResponseStatus::Completed => - if let Some(work_hex) = result.work { - let nonce_bytes = hex::decode(&work_hex) - .map_err(|e| format!("Failed to decode work hex '{}': {}", work_hex, e))?; - if nonce_bytes.len() == 64 { - let mut nonce = [0u8; 64]; - nonce.copy_from_slice(&nonce_bytes); - Ok(Some(nonce)) - } else { - Err(format!( - "Invalid decoded work length: {} bytes (expected 64)", - nonce_bytes.len() - )) +/// Background task that maintains the QUIC connection and handles messages. +async fn connection_handler( + addr: SocketAddr, + mut command_rx: mpsc::Receiver, + result_tx: mpsc::Sender, +) { + let mut reconnect_delay = std::time::Duration::from_secs(1); + const MAX_RECONNECT_DELAY: std::time::Duration = std::time::Duration::from_secs(30); + + loop { + log::info!("Connecting to miner at {}...", addr); + + match establish_connection(addr).await { + Ok((connection, send, recv)) => { + log::info!("Connected to miner at {}", addr); + reconnect_delay = std::time::Duration::from_secs(1); // Reset delay on success + + // Handle the connection until it fails + if let Err(e) = + handle_connection(connection, send, recv, &mut command_rx, &result_tx).await + { + log::warn!("Connection to miner lost: {}", e); } - } else { - Err("Missing 'work' field in completed mining result".to_string()) }, - ApiResponseStatus::Running => Ok(None), - ApiResponseStatus::NotFound => Err("Mining job not found".to_string()), - ApiResponseStatus::Failed => Err("Mining job failed (miner reported)".to_string()), - ApiResponseStatus::Cancelled => - Err("Mining job was cancelled (miner reported)".to_string()), - ApiResponseStatus::Error => Err("Miner reported an unspecified error".to_string()), - ApiResponseStatus::Accepted => - Err("Unexpected 'Accepted' status received from result endpoint".to_string()), + Err(e) => { + log::warn!("Failed to connect to miner at {}: {}", addr, e); + }, + } + + // Check for shutdown command before reconnecting + match command_rx.try_recv() { + Ok(MinerCommand::Shutdown) => { + log::info!("Miner client shutting down"); + return; + }, + _ => {}, + } + + log::info!("Reconnecting to miner in {:?}...", reconnect_delay); + tokio::time::sleep(reconnect_delay).await; + + // Exponential backoff + reconnect_delay = (reconnect_delay * 2).min(MAX_RECONNECT_DELAY); } } -pub(crate) async fn cancel_mining_job( - client: &Client, - miner_url: &str, - job_id: &str, -) -> Result<(), String> { - let response = client - .post(format!("{}/cancel/{}", miner_url, job_id)) - .send() +/// Establish a QUIC connection to the miner. +async fn establish_connection( + addr: SocketAddr, +) -> Result<(quinn::Connection, quinn::SendStream, quinn::RecvStream), String> { + // Create client config with insecure certificate verification + let mut crypto = rustls::ClientConfig::builder() + .with_safe_defaults() + .with_custom_certificate_verifier(Arc::new(InsecureCertVerifier)) + .with_no_client_auth(); + + // Set ALPN protocol to match the miner server + crypto.alpn_protocols = vec![b"quantus-miner".to_vec()]; + + let mut client_config = quinn::ClientConfig::new(Arc::new(crypto)); + + // Set transport config + // - Keep-alive pings every 10 seconds to prevent idle timeout + // - Max idle timeout of 60 seconds to handle gaps between mining jobs + let mut transport_config = quinn::TransportConfig::default(); + transport_config.keep_alive_interval(Some(std::time::Duration::from_secs(10))); + transport_config.max_idle_timeout(Some(std::time::Duration::from_secs(60).try_into().unwrap())); + client_config.transport_config(Arc::new(transport_config)); + + // Create endpoint + let mut endpoint = quinn::Endpoint::client("0.0.0.0:0".parse().unwrap()) + .map_err(|e| format!("Failed to create QUIC endpoint: {}", e))?; + endpoint.set_default_client_config(client_config); + + // Connect to the miner + let connection = endpoint + .connect(addr, "localhost") + .map_err(|e| format!("Failed to initiate connection: {}", e))? .await - .map_err(|e| format!("Failed to cancel mining job: {}", e))?; + .map_err(|e| format!("Failed to establish connection: {}", e))?; - let result: MiningResponse = response - .json() + // Open a bidirectional stream + let (send, recv) = connection + .open_bi() .await - .map_err(|e| format!("Failed to parse cancel response: {}", e))?; + .map_err(|e| format!("Failed to open stream: {}", e))?; - if result.status == ApiResponseStatus::Cancelled || result.status == ApiResponseStatus::NotFound - { - Ok(()) - } else { - Err(format!("Failed to cancel mining job (unexpected status): {:?}", result.status)) + Ok((connection, send, recv)) +} + +/// Handle an established connection, processing commands and receiving results. +async fn handle_connection( + _connection: quinn::Connection, + mut send: quinn::SendStream, + mut recv: quinn::RecvStream, + command_rx: &mut mpsc::Receiver, + result_tx: &mpsc::Sender, +) -> Result<(), String> { + loop { + tokio::select! { + // Handle commands from the main task + cmd = command_rx.recv() => { + match cmd { + Some(MinerCommand::SendJob(request)) => { + log::debug!("Sending NewJob to miner: job_id={}", request.job_id); + let msg = MinerMessage::NewJob(request); + write_message(&mut send, &msg) + .await + .map_err(|e| format!("Failed to send message: {}", e))?; + } + Some(MinerCommand::Shutdown) => { + log::info!("Connection handler shutting down"); + return Ok(()); + } + None => { + // Command channel closed, shut down + return Ok(()); + } + } + } + + // Handle incoming messages from the miner + msg_result = read_message(&mut recv) => { + match msg_result { + Ok(MinerMessage::JobResult(result)) => { + log::info!( + "Received JobResult from miner: job_id={}, status={:?}", + result.job_id, + result.status + ); + if result_tx.send(result).await.is_err() { + log::warn!("Failed to forward result (receiver dropped)"); + return Ok(()); + } + } + Ok(MinerMessage::NewJob(_)) => { + // Miner should not send NewJob to node + log::warn!("Received unexpected NewJob from miner, ignoring"); + } + Err(e) => { + if e.kind() == std::io::ErrorKind::UnexpectedEof { + return Err("Miner disconnected".to_string()); + } + return Err(format!("Failed to read message: {}", e)); + } + } + } + } + } +} + +/// A certificate verifier that accepts any certificate. +/// +/// This is used because the miner uses a self-signed certificate. +/// In production, you might want to use certificate pinning instead. +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, + _ocsp_response: &[u8], + _now: std::time::SystemTime, + ) -> Result { + // Accept any certificate + Ok(ServerCertVerified::assertion()) } } diff --git a/node/src/service.rs b/node/src/service.rs index 27a9b662..a81b3414 100644 --- a/node/src/service.rs +++ b/node/src/service.rs @@ -10,11 +10,11 @@ use sc_transaction_pool_api::{InPoolTransaction, OffchainTransactionPoolFactory, use sp_inherents::CreateInherentDataProviders; use tokio_util::sync::CancellationToken; -use crate::{external_miner_client, prometheus::ResonanceBusinessMetrics}; +use crate::{external_miner_client::QuicMinerClient, prometheus::ResonanceBusinessMetrics}; use codec::Encode; use jsonrpsee::tokio; use qpow_math::mine_range; -use reqwest::Client; +use quantus_miner_api::ApiResponseStatus; use sc_cli::TransactionPoolType; use sc_transaction_pool::TransactionPoolOptions; use sp_api::ProvideRuntimeApi; @@ -152,7 +152,7 @@ pub fn new_full< >( config: Configuration, rewards_address: AccountId32, - external_miner_url: Option, + external_miner_addr: Option, enable_peer_sharing: bool, ) -> Result { let sc_service::PartialComponents { @@ -302,9 +302,32 @@ pub fn new_full< task_manager.spawn_essential_handle().spawn("qpow-mining", None, async move { log::info!("⛏️ QPoW Mining task spawned"); let mut nonce: U512 = U512::one(); - let http_client = Client::new(); let mut current_job_id: Option = None; + // Connect to external miner if address is provided + let miner_client: Option = if let Some(ref addr_str) = external_miner_addr { + match addr_str.parse::() { + Ok(addr) => { + match QuicMinerClient::connect(addr).await { + Ok(client) => { + log::info!("⛏️ Connected to external miner at {}", addr); + Some(client) + }, + Err(e) => { + log::error!("⛏️ Failed to connect to external miner at {}: {}", addr, e); + None + } + } + }, + Err(e) => { + log::error!("⛏️ Invalid external miner address '{}': {}", addr_str, e); + None + } + } + } else { + None + }; + // Submit new mining job let mut mining_start_time = std::time::Instant::now(); log::info!("Mining start time: {:?}", mining_start_time); @@ -313,22 +336,7 @@ pub fn new_full< // Check for cancellation if mining_cancellation_token.is_cancelled() { log::info!("⛏️ QPoW Mining task shutting down gracefully"); - - // Cancel any pending external mining job - if let Some(job_id) = ¤t_job_id { - if let Some(miner_url) = &external_miner_url { - if let Err(e) = external_miner_client::cancel_mining_job( - &http_client, - miner_url, - job_id, - ) - .await - { - log::warn!("⛏️Failed to cancel mining job during shutdown: {}", e); - } - } - } - + // QUIC client will clean up on drop (connection closes, miner cancels job) break; } @@ -356,26 +364,8 @@ pub fn new_full< }; let version = worker_handle.version(); - // If external miner URL is provided, use external mining - if let Some(miner_url) = &external_miner_url { - // Fire-and-forget cancellation of previous job - don't wait for confirmation - // This reduces latency when switching to a new block - if let Some(old_job_id) = current_job_id.take() { - let cancel_client = http_client.clone(); - let cancel_url = miner_url.clone(); - tokio::spawn(async move { - if let Err(e) = external_miner_client::cancel_mining_job( - &cancel_client, - &cancel_url, - &old_job_id, - ) - .await - { - log::debug!("⛏️ Failed to cancel previous mining job {}: {}", old_job_id, e); - } - }); - } - + // If external miner is connected, use external mining + if let Some(ref miner) = miner_client { // Get current distance_threshold from runtime let difficulty = match client.runtime_api().get_difficulty(metadata.best_hash) { @@ -390,13 +380,11 @@ pub fn new_full< }, }; - // Generate new job ID + // Generate new job ID (NewJob implicitly cancels any previous job) let job_id = Uuid::new_v4().to_string(); current_job_id = Some(job_id.clone()); - if let Err(e) = external_miner_client::submit_mining_job( - &http_client, - miner_url, + if let Err(e) = miner.send_job( &job_id, &metadata.pre_hash, difficulty, @@ -413,54 +401,85 @@ pub fn new_full< continue; } - // Poll for results + // Wait for result (with periodic checks for new blocks) loop { - match external_miner_client::check_mining_result( - &http_client, - miner_url, - &job_id, - ) - .await + // Check for new block (would require sending new job) + if worker_handle + .metadata() + .map(|m| m.best_hash != metadata.best_hash) + .unwrap_or(false) { - Ok(Some(seal)) => { - let current_version = worker_handle.version(); - if current_version == version { - if futures::executor::block_on( - worker_handle.submit(seal.encode()), - ) { - let mining_time = mining_start_time.elapsed().as_secs(); - log::info!("🥇 Successfully mined and submitted a new block via external miner (mining time: {}s)", mining_time); - nonce = U512::one(); - mining_start_time = std::time::Instant::now(); - } else { - log::warn!( - "⛏️ Failed to submit mined block from external miner" - ); - nonce += U512::one(); - } - } else { - log::debug!(target: "miner", "Work from external miner is stale, discarding."); - } - break; - }, - Ok(None) => { - // Still working, check if metadata has changed - if worker_handle - .metadata() - .map(|m| m.best_hash != metadata.best_hash) - .unwrap_or(false) - { - break; + log::debug!(target: "miner", "New block detected, will send new job"); + break; + } + + // Check for cancellation + if mining_cancellation_token.is_cancelled() { + break; + } + + // Wait for result with timeout + match miner.recv_result_timeout(Duration::from_millis(500)).await { + Some(result) => { + // Check if this result is for our current job + if current_job_id.as_ref() != Some(&result.job_id) { + log::debug!(target: "miner", "Received stale result for job {}, ignoring", result.job_id); + continue; } - tokio::select! { - _ = tokio::time::sleep(Duration::from_millis(500)) => {}, - _ = mining_cancellation_token.cancelled() => return, + + match result.status { + ApiResponseStatus::Completed => { + if let Some(work_hex) = result.work { + match hex::decode(&work_hex) { + Ok(seal) if seal.len() == 64 => { + let current_version = worker_handle.version(); + if current_version == version { + // seal is already raw 64 bytes, don't SCALE-encode it + if futures::executor::block_on( + worker_handle.submit(seal), + ) { + let mining_time = mining_start_time.elapsed().as_secs(); + log::info!("🥇 Successfully mined and submitted a new block via external miner (mining time: {}s)", mining_time); + nonce = U512::one(); + mining_start_time = std::time::Instant::now(); + } else { + log::warn!( + "⛏️ Failed to submit mined block from external miner" + ); + nonce += U512::one(); + } + } else { + log::debug!(target: "miner", "Work from external miner is stale, discarding."); + } + }, + Ok(seal) => { + log::warn!("⛏️ Invalid seal length from miner: {} bytes", seal.len()); + }, + Err(e) => { + log::warn!("⛏️ Failed to decode work hex: {}", e); + } + } + } else { + log::warn!("⛏️ Completed result missing work field"); + } + break; + }, + ApiResponseStatus::Failed => { + log::warn!("⛏️ Mining job failed"); + break; + }, + ApiResponseStatus::Cancelled => { + log::debug!(target: "miner", "Mining job was cancelled"); + break; + }, + _ => { + log::debug!(target: "miner", "Unexpected result status: {:?}", result.status); + } } }, - Err(e) => { - log::warn!("⛏️Polling external miner result failed: {}", e); - break; - }, + None => { + // Timeout - continue waiting + } } } } else { From dbc51069c8714fb4bac471f1ee9343c2ab2c43af Mon Sep 17 00:00:00 2001 From: illuzen Date: Mon, 26 Jan 2026 18:16:24 +0800 Subject: [PATCH 02/13] refactor for readability --- node/src/service.rs | 265 ++++++++++++++++++++++++++------------------ 1 file changed, 159 insertions(+), 106 deletions(-) diff --git a/node/src/service.rs b/node/src/service.rs index a81b3414..37aaf056 100644 --- a/node/src/service.rs +++ b/node/src/service.rs @@ -14,19 +14,118 @@ use crate::{external_miner_client::QuicMinerClient, prometheus::ResonanceBusines use codec::Encode; use jsonrpsee::tokio; use qpow_math::mine_range; -use quantus_miner_api::ApiResponseStatus; +use quantus_miner_api::{ApiResponseStatus, MiningResult}; use sc_cli::TransactionPoolType; use sc_transaction_pool::TransactionPoolOptions; use sp_api::ProvideRuntimeApi; use sp_consensus::SyncOracle; use sp_consensus_qpow::QPoWApi; -use sp_core::{crypto::AccountId32, U512}; +use sp_core::{crypto::AccountId32, H256, U512}; use std::{sync::Arc, time::Duration}; use uuid::Uuid; /// Frequency of block import logging. Every 1000 blocks. const LOG_FREQUENCY: u64 = 1000; +// ============================================================================ +// External Mining Helper Types and Functions +// ============================================================================ + +/// Result of waiting for an external mining result. +enum ExternalMiningOutcome { + /// Successfully found a valid seal (64 bytes). + Success(Vec), + /// Mining completed but result was invalid, stale, cancelled, or failed. + Failed, + /// New block arrived, need to send new job. + NewBlock, + /// Shutdown requested. + Shutdown, +} + +/// Parse a mining result and extract the seal if valid. +fn parse_mining_result(result: &MiningResult, expected_job_id: &str) -> Option> { + // Check job ID matches + if result.job_id != expected_job_id { + log::debug!(target: "miner", "Received stale result for job {}, ignoring", result.job_id); + return None; + } + + // Check status + if result.status != ApiResponseStatus::Completed { + match result.status { + ApiResponseStatus::Failed => log::warn!("⛏️ Mining job failed"), + ApiResponseStatus::Cancelled => { + log::debug!(target: "miner", "Mining job was cancelled") + }, + _ => log::debug!(target: "miner", "Unexpected result status: {:?}", result.status), + } + return None; + } + + // Extract and decode work + let work_hex = result.work.as_ref()?; + match hex::decode(work_hex) { + Ok(seal) if seal.len() == 64 => Some(seal), + Ok(seal) => { + log::warn!("⛏️ Invalid seal length from miner: {} bytes", seal.len()); + None + }, + Err(e) => { + log::warn!("⛏️ Failed to decode work hex: {}", e); + None + }, + } +} + +/// Wait for a mining result from the external miner. +/// +/// Returns when: +/// - A valid result is received +/// - A new block is detected (need to send new job) +/// - The operation is cancelled +/// +/// The `check_new_block` closure should return `true` if a new block has arrived. +async fn wait_for_mining_result( + miner: &QuicMinerClient, + job_id: &str, + check_new_block: F, + cancellation_token: &CancellationToken, +) -> ExternalMiningOutcome +where + F: Fn() -> bool, +{ + loop { + // Check for new block + if check_new_block() { + log::debug!(target: "miner", "New block detected, will send new job"); + return ExternalMiningOutcome::NewBlock; + } + + // Check for shutdown + if cancellation_token.is_cancelled() { + return ExternalMiningOutcome::Shutdown; + } + + // Wait for result with timeout + match miner.recv_result_timeout(Duration::from_millis(500)).await { + Some(result) => { + if let Some(seal) = parse_mining_result(&result, job_id) { + return ExternalMiningOutcome::Success(seal); + } + // For completed but invalid results, or failed/cancelled, stop waiting + if result.job_id == job_id { + return ExternalMiningOutcome::Failed; + } + // Stale result for different job, keep waiting + }, + None => { + // Timeout, continue waiting + }, + } + } +} + pub(crate) type FullClient = sc_service::TFullClient< Block, RuntimeApi, @@ -366,34 +465,26 @@ pub fn new_full< // If external miner is connected, use external mining if let Some(ref miner) = miner_client { - // Get current distance_threshold from runtime - let difficulty = - match client.runtime_api().get_difficulty(metadata.best_hash) { - Ok(d) => d, - Err(e) => { - log::warn!("⛏️Failed to get difficulty: {:?}", e); - tokio::select! { - _ = tokio::time::sleep(Duration::from_millis(250)) => {}, - _ = mining_cancellation_token.cancelled() => continue, - } - continue; - }, - }; + // Get difficulty from runtime + let difficulty = match client.runtime_api().get_difficulty(metadata.best_hash) { + Ok(d) => d, + Err(e) => { + log::warn!("⛏️ Failed to get difficulty: {:?}", e); + tokio::select! { + _ = tokio::time::sleep(Duration::from_millis(250)) => {}, + _ = mining_cancellation_token.cancelled() => continue, + } + continue; + }, + }; - // Generate new job ID (NewJob implicitly cancels any previous job) + // Submit job to external miner let job_id = Uuid::new_v4().to_string(); - current_job_id = Some(job_id.clone()); - - if let Err(e) = miner.send_job( - &job_id, - &metadata.pre_hash, - difficulty, - nonce, - U512::max_value(), - ) - .await + if let Err(e) = miner + .send_job(&job_id, &metadata.pre_hash, difficulty, nonce, U512::max_value()) + .await { - log::warn!("⛏️Failed to submit mining job: {}", e); + log::warn!("⛏️ Failed to submit mining job: {}", e); tokio::select! { _ = tokio::time::sleep(Duration::from_millis(250)) => {}, _ = mining_cancellation_token.cancelled() => continue, @@ -401,86 +492,48 @@ pub fn new_full< continue; } - // Wait for result (with periodic checks for new blocks) - loop { - // Check for new block (would require sending new job) - if worker_handle - .metadata() - .map(|m| m.best_hash != metadata.best_hash) - .unwrap_or(false) - { - log::debug!(target: "miner", "New block detected, will send new job"); - break; - } - - // Check for cancellation - if mining_cancellation_token.is_cancelled() { - break; - } - - // Wait for result with timeout - match miner.recv_result_timeout(Duration::from_millis(500)).await { - Some(result) => { - // Check if this result is for our current job - if current_job_id.as_ref() != Some(&result.job_id) { - log::debug!(target: "miner", "Received stale result for job {}, ignoring", result.job_id); - continue; - } - - match result.status { - ApiResponseStatus::Completed => { - if let Some(work_hex) = result.work { - match hex::decode(&work_hex) { - Ok(seal) if seal.len() == 64 => { - let current_version = worker_handle.version(); - if current_version == version { - // seal is already raw 64 bytes, don't SCALE-encode it - if futures::executor::block_on( - worker_handle.submit(seal), - ) { - let mining_time = mining_start_time.elapsed().as_secs(); - log::info!("🥇 Successfully mined and submitted a new block via external miner (mining time: {}s)", mining_time); - nonce = U512::one(); - mining_start_time = std::time::Instant::now(); - } else { - log::warn!( - "⛏️ Failed to submit mined block from external miner" - ); - nonce += U512::one(); - } - } else { - log::debug!(target: "miner", "Work from external miner is stale, discarding."); - } - }, - Ok(seal) => { - log::warn!("⛏️ Invalid seal length from miner: {} bytes", seal.len()); - }, - Err(e) => { - log::warn!("⛏️ Failed to decode work hex: {}", e); - } - } - } else { - log::warn!("⛏️ Completed result missing work field"); - } - break; - }, - ApiResponseStatus::Failed => { - log::warn!("⛏️ Mining job failed"); - break; - }, - ApiResponseStatus::Cancelled => { - log::debug!(target: "miner", "Mining job was cancelled"); - break; - }, - _ => { - log::debug!(target: "miner", "Unexpected result status: {:?}", result.status); - } - } - }, - None => { - // Timeout - continue waiting + // Wait for result + let best_hash = metadata.best_hash; + let outcome = wait_for_mining_result( + miner, + &job_id, + || { + worker_handle + .metadata() + .map(|m| m.best_hash != best_hash) + .unwrap_or(false) + }, + &mining_cancellation_token, + ) + .await; + + match outcome { + ExternalMiningOutcome::Success(seal) => { + let current_version = worker_handle.version(); + if current_version != version { + log::debug!(target: "miner", "Work from external miner is stale, discarding."); + } else if futures::executor::block_on(worker_handle.submit(seal)) { + let mining_time = mining_start_time.elapsed().as_secs(); + log::info!( + "🥇 Successfully mined and submitted a new block via external miner (mining time: {}s)", + mining_time + ); + nonce = U512::one(); + mining_start_time = std::time::Instant::now(); + } else { + log::warn!("⛏️ Failed to submit mined block from external miner"); + nonce += U512::one(); } - } + }, + ExternalMiningOutcome::NewBlock => { + // Loop will continue and send new job + }, + ExternalMiningOutcome::Shutdown => { + break; + }, + ExternalMiningOutcome::Failed => { + // Continue to next iteration + }, } } else { // Local mining: try a range of N sequential nonces using optimized path From feb71388363f096ab6430fa71e20b5707d57ae0b Mon Sep 17 00:00:00 2001 From: illuzen Date: Mon, 26 Jan 2026 18:32:30 +0800 Subject: [PATCH 03/13] simplify --- node/src/service.rs | 98 ++++++++++++++------------------------------- 1 file changed, 31 insertions(+), 67 deletions(-) diff --git a/node/src/service.rs b/node/src/service.rs index 37aaf056..d46e3f7c 100644 --- a/node/src/service.rs +++ b/node/src/service.rs @@ -31,18 +31,6 @@ const LOG_FREQUENCY: u64 = 1000; // External Mining Helper Types and Functions // ============================================================================ -/// Result of waiting for an external mining result. -enum ExternalMiningOutcome { - /// Successfully found a valid seal (64 bytes). - Success(Vec), - /// Mining completed but result was invalid, stale, cancelled, or failed. - Failed, - /// New block arrived, need to send new job. - NewBlock, - /// Shutdown requested. - Shutdown, -} - /// Parse a mining result and extract the seal if valid. fn parse_mining_result(result: &MiningResult, expected_job_id: &str) -> Option> { // Check job ID matches @@ -80,42 +68,32 @@ fn parse_mining_result(result: &MiningResult, expected_job_id: &str) -> Option( miner: &QuicMinerClient, job_id: &str, - check_new_block: F, - cancellation_token: &CancellationToken, -) -> ExternalMiningOutcome + should_stop: F, +) -> Option> where F: Fn() -> bool, { loop { - // Check for new block - if check_new_block() { - log::debug!(target: "miner", "New block detected, will send new job"); - return ExternalMiningOutcome::NewBlock; + if should_stop() { + return None; } - // Check for shutdown - if cancellation_token.is_cancelled() { - return ExternalMiningOutcome::Shutdown; - } - - // Wait for result with timeout match miner.recv_result_timeout(Duration::from_millis(500)).await { Some(result) => { if let Some(seal) = parse_mining_result(&result, job_id) { - return ExternalMiningOutcome::Success(seal); + return Some(seal); } // For completed but invalid results, or failed/cancelled, stop waiting if result.job_id == job_id { - return ExternalMiningOutcome::Failed; + return None; } // Stale result for different job, keep waiting }, @@ -494,46 +472,32 @@ pub fn new_full< // Wait for result let best_hash = metadata.best_hash; - let outcome = wait_for_mining_result( - miner, - &job_id, - || { - worker_handle + let outcome = wait_for_mining_result(miner, &job_id, || { + // Stop if new block arrived or shutdown requested + mining_cancellation_token.is_cancelled() + || worker_handle .metadata() .map(|m| m.best_hash != best_hash) .unwrap_or(false) - }, - &mining_cancellation_token, - ) + }) .await; - match outcome { - ExternalMiningOutcome::Success(seal) => { - let current_version = worker_handle.version(); - if current_version != version { - log::debug!(target: "miner", "Work from external miner is stale, discarding."); - } else if futures::executor::block_on(worker_handle.submit(seal)) { - let mining_time = mining_start_time.elapsed().as_secs(); - log::info!( - "🥇 Successfully mined and submitted a new block via external miner (mining time: {}s)", - mining_time - ); - nonce = U512::one(); - mining_start_time = std::time::Instant::now(); - } else { - log::warn!("⛏️ Failed to submit mined block from external miner"); - nonce += U512::one(); - } - }, - ExternalMiningOutcome::NewBlock => { - // Loop will continue and send new job - }, - ExternalMiningOutcome::Shutdown => { - break; - }, - ExternalMiningOutcome::Failed => { - // Continue to next iteration - }, + if let Some(seal) = outcome { + let current_version = worker_handle.version(); + if current_version != version { + log::debug!(target: "miner", "Work from external miner is stale, discarding."); + } else if futures::executor::block_on(worker_handle.submit(seal)) { + let mining_time = mining_start_time.elapsed().as_secs(); + log::info!( + "🥇 Successfully mined and submitted a new block via external miner (mining time: {}s)", + mining_time + ); + nonce = U512::one(); + mining_start_time = std::time::Instant::now(); + } else { + log::warn!("⛏️ Failed to submit mined block from external miner"); + nonce += U512::one(); + } } } else { // Local mining: try a range of N sequential nonces using optimized path From 438a0dbaf99f46a18c34922830cc1406e8c2b973 Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 27 Jan 2026 09:38:53 +0800 Subject: [PATCH 04/13] miner initiates, multiple miners supported --- Cargo.lock | 1 + EXTERNAL_MINER_PROTOCOL.md | 127 ++++++---- miner-api/src/lib.rs | 18 +- node/Cargo.toml | 1 + node/src/cli.rs | 7 +- node/src/command.rs | 4 +- node/src/external_miner_client.rs | 288 ----------------------- node/src/main.rs | 2 +- node/src/miner_server.rs | 370 ++++++++++++++++++++++++++++++ node/src/service.rs | 278 +++++++++++----------- 10 files changed, 613 insertions(+), 483 deletions(-) delete mode 100644 node/src/external_miner_client.rs create mode 100644 node/src/miner_server.rs diff --git a/Cargo.lock b/Cargo.lock index f1bbe038..356d6ed5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9093,6 +9093,7 @@ dependencies = [ "quantus-runtime", "quinn 0.10.2", "rand 0.8.5", + "rcgen", "rustls 0.21.12", "sc-basic-authorship", "sc-cli", diff --git a/EXTERNAL_MINER_PROTOCOL.md b/EXTERNAL_MINER_PROTOCOL.md index b03f4611..0fec0065 100644 --- a/EXTERNAL_MINER_PROTOCOL.md +++ b/EXTERNAL_MINER_PROTOCOL.md @@ -1,10 +1,10 @@ # External Miner Protocol Specification -This document defines the QUIC-based protocol for communication between the Quantus 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 miner service over a persistent QUIC connection. 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 pushes the result back 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 @@ -13,24 +13,42 @@ The node delegates the mining task (finding a valid nonce) to an external miner - **Multiplexed streams**: Multiple operations on single connection - **Built-in TLS**: Encrypted by default -## Protocol Design +## Architecture ### Connection Model ``` -┌─────────────────────────┐ QUIC Connection ┌─────────────────────────┐ -│ Blockchain Node │◄═══════════════════════════════►│ External Miner │ -│ (QUIC Client) │ │ (QUIC Server) │ -│ │ Bidirectional Stream │ │ -│ Sends: NewJob │ ─────────────────────────────► │ Receives: NewJob │ -│ Receives: JobResult │ ◄───────────────────────────── │ Sends: JobResult │ -└─────────────────────────┘ └─────────────────────────┘ + ┌─────────────────────────────────┐ + │ 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 client, connecting to the miner -- **Miner** acts as the QUIC server, listening on port 9833 (default) -- Single bidirectional stream per connection +- **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 @@ -74,8 +92,8 @@ pub enum MinerMessage { | `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) | -| `nonce_start` | String | Starting nonce (128 hex chars, no 0x prefix) | -| `nonce_end` | String | Ending nonce (128 hex chars, no 0x prefix) | + +Note: Nonce range is not specified - each miner independently selects a random starting point. ### MiningResult @@ -102,19 +120,20 @@ pub enum MinerMessage { ### Normal Mining Flow ``` -Node Miner +Miner Node │ │ │──── QUIC Connect ─────────────────────────►│ │◄─── Connection Established ────────────────│ │ │ - │──── NewJob { job_id: "abc", ... } ────────►│ - │ │ (starts mining) + │◄─── NewJob { job_id: "abc", ... } ─────────│ │ │ - │◄─── JobResult { job_id: "abc", ... } ──────│ (found solution!) + │ (picks random nonce, starts mining) │ + │ │ + │──── JobResult { job_id: "abc", ... } ─────►│ (found solution!) │ │ │ (node submits block, gets new work) │ │ │ - │──── NewJob { job_id: "def", ... } ────────►│ + │◄─── NewJob { job_id: "def", ... } ─────────│ │ │ ``` @@ -123,17 +142,33 @@ Node Miner 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: ``` -Node Miner +Miner Node + │ │ + │◄─── NewJob { job_id: "abc", ... } ─────────│ │ │ - │──── NewJob { job_id: "abc", ... } ────────►│ - │ │ (mining "abc") + │ (mining "abc") │ │ │ │ (new block arrives at node!) │ │ │ - │──── NewJob { job_id: "def", ... } ────────►│ - │ │ (cancels "abc", starts "def") + │◄─── NewJob { job_id: "def", ... } ─────────│ │ │ - │◄─── JobResult { 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 @@ -141,15 +176,15 @@ Node Miner If a result arrives for an old job, the node discards it: ``` -Node Miner +Miner Node │ │ - │──── NewJob { job_id: "abc", ... } ────────►│ + │◄─── NewJob { job_id: "abc", ... } ─────────│ │ │ - │──── NewJob { job_id: "def", ... } ────────►│ (almost simultaneous) + │◄─── NewJob { job_id: "def", ... } ─────────│ (almost simultaneous) │ │ - │◄─── JobResult { job_id: "abc", ... } ──────│ (stale, node ignores) + │──── JobResult { job_id: "abc", ... } ─────►│ (stale, node ignores) │ │ - │◄─── JobResult { job_id: "def", ... } ──────│ (current, node uses) + │──── JobResult { job_id: "def", ... } ─────►│ (current, node uses) ``` ## Configuration @@ -157,22 +192,22 @@ Node Miner ### Node ```bash -# Connect to external miner -quantus-node --external-miner-addr 127.0.0.1:9833 +# Listen for external miner connections on port 9833 +quantus-node --miner-listen-port 9833 ``` ### Miner ```bash -# Start QUIC server -quantus-miner serve --quic-port 9833 +# Connect to node +quantus-miner serve --node-addr 127.0.0.1:9833 ``` ## TLS Configuration -The miner generates a self-signed TLS certificate at startup. The node skips certificate verification by default (insecure mode). For production deployments, consider: +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 node to accept only specific certificate fingerprints +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 @@ -180,31 +215,21 @@ The miner generates a self-signed TLS certificate at startup. The node skips cer ### Connection Loss -The node automatically reconnects with exponential backoff: +The miner automatically reconnects with exponential backoff: - Initial delay: 1 second - Maximum delay: 30 seconds -During reconnection, the node falls back to local mining if available. +The node continues operating with remaining connected miners. ### Validation Errors If the miner receives an invalid `MiningRequest`, it sends a `JobResult` with status `failed`. -## Migration from HTTP - -If you were using the previous HTTP-based protocol: - -| Old (HTTP) | New (QUIC) | -|------------|------------| -| `--external-miner-url http://...` | `--external-miner-addr host:port` | -| `--port 9833` | `--quic-port 9833` | -| `POST /mine` | `MinerMessage::NewJob` | -| `GET /result/{id}` | Results pushed automatically | -| `POST /cancel/{id}` | Implicit (send new job) | - ## Notes - All hex values should be sent **without** the `0x` prefix - The miner implements validation logic from `qpow_math::is_valid_nonce` - The node uses the `work` field from `MiningResult` to construct `QPoWSeal` - ALPN protocol identifier: `quantus-miner` +- Each miner independently generates a random nonce starting point using cryptographically secure randomness +- With a 512-bit nonce space, collision between miners is statistically impossible diff --git a/miner-api/src/lib.rs b/miner-api/src/lib.rs index 2b922320..d86805e6 100644 --- a/miner-api/src/lib.rs +++ b/miner-api/src/lib.rs @@ -19,11 +19,16 @@ pub enum ApiResponseStatus { /// QUIC protocol messages exchanged between node and miner. /// -/// The protocol is simple: +/// The protocol is: +/// - Miner sends `Ready` immediately after connecting to establish the stream /// - Node sends `NewJob` to submit a mining job (implicitly cancels any previous job) /// - Miner sends `JobResult` when mining completes #[derive(Serialize, Deserialize, Debug, Clone)] pub enum MinerMessage { + /// Miner → Node: Sent immediately after connecting to establish the stream. + /// This is required because QUIC streams are lazily initialized. + Ready, + /// Node → Miner: Submit a new mining job. /// If a job is already running, it will be cancelled and replaced. NewJob(MiningRequest), @@ -69,18 +74,17 @@ pub async fn read_message(reader: &mut R) -> std::io::Resu .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e)) } -/// Request payload sent from Node to Miner (`/mine` endpoint). +/// Request payload sent from Node to Miner. +/// +/// The miner will choose its own random starting nonce, enabling multiple +/// miners to work on the same job without coordination. #[derive(Serialize, Deserialize, Debug, Clone)] pub struct MiningRequest { pub job_id: String, /// Hex encoded header hash (32 bytes -> 64 chars, no 0x prefix) pub mining_hash: String, - /// Distance threshold (u64 as string) + /// Distance threshold (U512 as decimal string) pub distance_threshold: String, - /// Hex encoded start nonce (U512 -> 128 chars, no 0x prefix) - pub nonce_start: String, - /// Hex encoded end nonce (U512 -> 128 chars, no 0x prefix) - pub nonce_end: String, } /// Response payload for job submission (`/mine`) and cancellation (`/cancel`). diff --git a/node/Cargo.toml b/node/Cargo.toml index 28532e75..b1b6af7d 100644 --- a/node/Cargo.toml +++ b/node/Cargo.toml @@ -43,6 +43,7 @@ rand = { workspace = true, default-features = false, features = [ "alloc", "getrandom", ] } +rcgen = "0.11" rustls = { version = "0.21", default-features = false, features = ["dangerous_configuration", "quic"] } sc-basic-authorship.default-features = true sc-basic-authorship.workspace = true diff --git a/node/src/cli.rs b/node/src/cli.rs index ef17f54b..f52ff1d2 100644 --- a/node/src/cli.rs +++ b/node/src/cli.rs @@ -13,9 +13,10 @@ pub struct Cli { #[arg(long, value_name = "REWARDS_ADDRESS")] pub rewards_address: Option, - /// Specify the address of an external QPoW miner service (e.g., "127.0.0.1:9833") - #[arg(long, value_name = "EXTERNAL_MINER_ADDR")] - pub external_miner_addr: Option, + /// Port to listen for external miner connections (e.g., 9833). + /// When set, the node will wait for miners to connect instead of mining locally. + #[arg(long, value_name = "PORT")] + pub miner_listen_port: Option, /// Enable peer sharing via RPC endpoint #[arg(long)] diff --git a/node/src/command.rs b/node/src/command.rs index 75a511cd..b44d75ac 100644 --- a/node/src/command.rs +++ b/node/src/command.rs @@ -480,9 +480,7 @@ pub fn run() -> sc_cli::Result<()> { quantus_runtime::opaque::Block, ::Hash, >, - >( - config, rewards_account, cli.external_miner_addr.clone(), cli.enable_peer_sharing - ) + >(config, rewards_account, cli.miner_listen_port, cli.enable_peer_sharing) .map_err(sc_cli::Error::Service) }) }, diff --git a/node/src/external_miner_client.rs b/node/src/external_miner_client.rs deleted file mode 100644 index 9ae88557..00000000 --- a/node/src/external_miner_client.rs +++ /dev/null @@ -1,288 +0,0 @@ -//! QUIC client for communicating with external miners. -//! -//! This module provides a persistent QUIC connection to an external miner service, -//! enabling bidirectional streaming for mining job submission and result delivery. -//! -//! # Protocol -//! -//! - Node sends `MinerMessage::NewJob` to submit a mining job (implicitly cancels any previous) -//! - Miner sends `MinerMessage::JobResult` when mining completes -//! -//! # Connection Management -//! -//! The client maintains a persistent connection and automatically reconnects on failure. - -use std::{net::SocketAddr, sync::Arc}; - -use jsonrpsee::tokio; -use quantus_miner_api::{read_message, write_message, MinerMessage, MiningRequest, MiningResult}; -use rustls::client::ServerCertVerified; -use sp_core::{H256, U512}; -use tokio::sync::{mpsc, Mutex}; - -/// A QUIC client for communicating with an external miner. -/// -/// This client maintains a persistent connection and provides methods to send -/// mining jobs and receive results asynchronously. -pub struct QuicMinerClient { - /// The address of the miner to connect to. - addr: SocketAddr, - /// Channel to send commands to the connection handler task. - command_tx: mpsc::Sender, - /// Channel to receive mining results. - result_rx: Mutex>, -} - -/// Commands sent to the connection handler task. -enum MinerCommand { - SendJob(MiningRequest), - Shutdown, -} - -impl QuicMinerClient { - /// Create a new QUIC miner client and connect to the miner. - /// - /// This spawns a background task that maintains the connection and handles - /// sending jobs and receiving results. - pub async fn connect(addr: SocketAddr) -> Result { - let (command_tx, command_rx) = mpsc::channel::(16); - let (result_tx, result_rx) = mpsc::channel::(16); - - // Spawn the connection handler task - let addr_clone = addr; - tokio::spawn(async move { - connection_handler(addr_clone, command_rx, result_tx).await; - }); - - log::info!("QUIC miner client created for {}", addr); - - Ok(Self { addr, command_tx, result_rx: Mutex::new(result_rx) }) - } - - /// Send a mining job to the miner. - /// - /// This sends a `NewJob` message which implicitly cancels any previous job. - pub async fn send_job( - &self, - job_id: &str, - mining_hash: &H256, - distance_threshold: U512, - nonce_start: U512, - nonce_end: U512, - ) -> Result<(), String> { - let request = MiningRequest { - job_id: job_id.to_string(), - mining_hash: hex::encode(mining_hash.as_bytes()), - distance_threshold: distance_threshold.to_string(), - nonce_start: format!("{:0128x}", nonce_start), - nonce_end: format!("{:0128x}", nonce_end), - }; - - self.command_tx - .send(MinerCommand::SendJob(request)) - .await - .map_err(|e| format!("Failed to send job command: {}", e))?; - - Ok(()) - } - - /// Try to receive a mining result without blocking. - /// - /// Returns `Some(result)` if a result is available, `None` otherwise. - pub async fn try_recv_result(&self) -> Option { - let mut rx = self.result_rx.lock().await; - rx.try_recv().ok() - } - - /// Wait for a mining result with a timeout. - /// - /// Returns the result if one is received within the timeout, or `None` if the timeout expires. - pub async fn recv_result_timeout(&self, timeout: std::time::Duration) -> Option { - let mut rx = self.result_rx.lock().await; - tokio::time::timeout(timeout, rx.recv()).await.ok().flatten() - } - - /// Get the address of the miner this client is connected to. - pub fn addr(&self) -> SocketAddr { - self.addr - } -} - -impl Drop for QuicMinerClient { - fn drop(&mut self) { - // Try to send shutdown command (non-blocking) - let _ = self.command_tx.try_send(MinerCommand::Shutdown); - } -} - -/// Background task that maintains the QUIC connection and handles messages. -async fn connection_handler( - addr: SocketAddr, - mut command_rx: mpsc::Receiver, - result_tx: mpsc::Sender, -) { - let mut reconnect_delay = std::time::Duration::from_secs(1); - const MAX_RECONNECT_DELAY: std::time::Duration = std::time::Duration::from_secs(30); - - loop { - log::info!("Connecting to miner at {}...", addr); - - match establish_connection(addr).await { - Ok((connection, send, recv)) => { - log::info!("Connected to miner at {}", addr); - reconnect_delay = std::time::Duration::from_secs(1); // Reset delay on success - - // Handle the connection until it fails - if let Err(e) = - handle_connection(connection, send, recv, &mut command_rx, &result_tx).await - { - log::warn!("Connection to miner lost: {}", e); - } - }, - Err(e) => { - log::warn!("Failed to connect to miner at {}: {}", addr, e); - }, - } - - // Check for shutdown command before reconnecting - match command_rx.try_recv() { - Ok(MinerCommand::Shutdown) => { - log::info!("Miner client shutting down"); - return; - }, - _ => {}, - } - - log::info!("Reconnecting to miner in {:?}...", reconnect_delay); - tokio::time::sleep(reconnect_delay).await; - - // Exponential backoff - reconnect_delay = (reconnect_delay * 2).min(MAX_RECONNECT_DELAY); - } -} - -/// Establish a QUIC connection to the miner. -async fn establish_connection( - addr: SocketAddr, -) -> Result<(quinn::Connection, quinn::SendStream, quinn::RecvStream), String> { - // Create client config with insecure certificate verification - let mut crypto = rustls::ClientConfig::builder() - .with_safe_defaults() - .with_custom_certificate_verifier(Arc::new(InsecureCertVerifier)) - .with_no_client_auth(); - - // Set ALPN protocol to match the miner server - crypto.alpn_protocols = vec![b"quantus-miner".to_vec()]; - - let mut client_config = quinn::ClientConfig::new(Arc::new(crypto)); - - // Set transport config - // - Keep-alive pings every 10 seconds to prevent idle timeout - // - Max idle timeout of 60 seconds to handle gaps between mining jobs - let mut transport_config = quinn::TransportConfig::default(); - transport_config.keep_alive_interval(Some(std::time::Duration::from_secs(10))); - transport_config.max_idle_timeout(Some(std::time::Duration::from_secs(60).try_into().unwrap())); - client_config.transport_config(Arc::new(transport_config)); - - // Create endpoint - let mut endpoint = quinn::Endpoint::client("0.0.0.0:0".parse().unwrap()) - .map_err(|e| format!("Failed to create QUIC endpoint: {}", e))?; - endpoint.set_default_client_config(client_config); - - // Connect to the miner - let connection = endpoint - .connect(addr, "localhost") - .map_err(|e| format!("Failed to initiate connection: {}", e))? - .await - .map_err(|e| format!("Failed to establish connection: {}", e))?; - - // Open a bidirectional stream - let (send, recv) = connection - .open_bi() - .await - .map_err(|e| format!("Failed to open stream: {}", e))?; - - Ok((connection, send, recv)) -} - -/// Handle an established connection, processing commands and receiving results. -async fn handle_connection( - _connection: quinn::Connection, - mut send: quinn::SendStream, - mut recv: quinn::RecvStream, - command_rx: &mut mpsc::Receiver, - result_tx: &mpsc::Sender, -) -> Result<(), String> { - loop { - tokio::select! { - // Handle commands from the main task - cmd = command_rx.recv() => { - match cmd { - Some(MinerCommand::SendJob(request)) => { - log::debug!("Sending NewJob to miner: job_id={}", request.job_id); - let msg = MinerMessage::NewJob(request); - write_message(&mut send, &msg) - .await - .map_err(|e| format!("Failed to send message: {}", e))?; - } - Some(MinerCommand::Shutdown) => { - log::info!("Connection handler shutting down"); - return Ok(()); - } - None => { - // Command channel closed, shut down - return Ok(()); - } - } - } - - // Handle incoming messages from the miner - msg_result = read_message(&mut recv) => { - match msg_result { - Ok(MinerMessage::JobResult(result)) => { - log::info!( - "Received JobResult from miner: job_id={}, status={:?}", - result.job_id, - result.status - ); - if result_tx.send(result).await.is_err() { - log::warn!("Failed to forward result (receiver dropped)"); - return Ok(()); - } - } - Ok(MinerMessage::NewJob(_)) => { - // Miner should not send NewJob to node - log::warn!("Received unexpected NewJob from miner, ignoring"); - } - Err(e) => { - if e.kind() == std::io::ErrorKind::UnexpectedEof { - return Err("Miner disconnected".to_string()); - } - return Err(format!("Failed to read message: {}", e)); - } - } - } - } - } -} - -/// A certificate verifier that accepts any certificate. -/// -/// This is used because the miner uses a self-signed certificate. -/// In production, you might want to use certificate pinning instead. -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, - _ocsp_response: &[u8], - _now: std::time::SystemTime, - ) -> Result { - // Accept any certificate - Ok(ServerCertVerified::assertion()) - } -} diff --git a/node/src/main.rs b/node/src/main.rs index f1fb0e64..f0627141 100644 --- a/node/src/main.rs +++ b/node/src/main.rs @@ -5,7 +5,7 @@ mod benchmarking; mod chain_spec; mod cli; mod command; -mod external_miner_client; +mod miner_server; mod prometheus; mod rpc; mod service; diff --git a/node/src/miner_server.rs b/node/src/miner_server.rs new file mode 100644 index 00000000..b8594e4f --- /dev/null +++ b/node/src/miner_server.rs @@ -0,0 +1,370 @@ +//! QUIC server for accepting connections from external miners. +//! +//! This module provides a QUIC server that miners connect to. It supports +//! multiple concurrent miners, broadcasting jobs to all connected miners +//! and collecting results. +//! +//! # Architecture +//! +//! ```text +//! ┌──────────┐ +//! │ Miner 1 │ ────┐ +//! └──────────┘ │ +//! │ ┌─────────────────┐ +//! ┌──────────┐ ├────>│ MinerServer │ +//! │ Miner 2 │ ────┤ │ (QUIC Server) │ +//! └──────────┘ │ └─────────────────┘ +//! │ +//! ┌──────────┐ │ +//! │ Miner 3 │ ────┘ +//! └──────────┘ +//! ``` +//! +//! # Protocol +//! +//! - Node sends `MinerMessage::NewJob` to all connected miners +//! - Each miner independently selects a random nonce starting point +//! - First miner to find a valid solution sends `MinerMessage::JobResult` +//! - When a new job is broadcast, miners implicitly cancel their current work + +use std::{ + collections::HashMap, + sync::{ + atomic::{AtomicU64, Ordering}, + Arc, + }, + time::Duration, +}; + +use jsonrpsee::tokio; +use quantus_miner_api::{read_message, write_message, MinerMessage, MiningRequest, MiningResult}; +use tokio::sync::{mpsc, RwLock}; + +/// A QUIC server that accepts connections from miners. +pub struct MinerServer { + /// Connected miners, keyed by unique ID. + miners: Arc>>, + /// Channel to receive results from any miner. + result_rx: tokio::sync::Mutex>, + /// Sender cloned to each miner connection handler. + result_tx: mpsc::Sender, + /// Current job being mined (sent to newly connecting miners). + current_job: Arc>>, + /// Counter for assigning unique miner IDs. + next_miner_id: AtomicU64, +} + +/// Handle for communicating with a connected miner. +struct MinerHandle { + /// Channel to send jobs to this miner. + job_tx: mpsc::Sender, +} + +impl MinerServer { + /// Start the QUIC server and listen for miner connections. + /// + /// This spawns a background task that accepts incoming connections. + pub async fn start(port: u16) -> Result, String> { + let (result_tx, result_rx) = mpsc::channel::(64); + + let server = Arc::new(Self { + miners: Arc::new(RwLock::new(HashMap::new())), + result_rx: tokio::sync::Mutex::new(result_rx), + result_tx, + current_job: Arc::new(RwLock::new(None)), + next_miner_id: AtomicU64::new(1), + }); + + // Start the acceptor task + let server_clone = server.clone(); + let endpoint = create_server_endpoint(port).await?; + + tokio::spawn(async move { + acceptor_task(endpoint, server_clone).await; + }); + + log::info!("⛏️ Miner server listening on port {}", port); + + Ok(server) + } + + /// Broadcast a job to all connected miners. + /// + /// This also stores the job so newly connecting miners receive it. + pub async fn broadcast_job(&self, job: MiningRequest) { + // Store as current job for new miners + { + let mut current = self.current_job.write().await; + *current = Some(job.clone()); + } + + // Send to all connected miners + let miners = self.miners.read().await; + let miner_count = miners.len(); + + if miner_count == 0 { + log::debug!("No miners connected, job queued for when miners connect"); + return; + } + + log::debug!("Broadcasting job {} to {} miner(s)", job.job_id, miner_count); + + for (id, handle) in miners.iter() { + if let Err(e) = handle.job_tx.try_send(job.clone()) { + log::warn!("Failed to send job to miner {}: {}", id, e); + } + } + } + + /// Wait for a mining result from any miner. + /// + /// Returns `None` if the channel is closed. + pub async fn recv_result(&self) -> Option { + let mut rx = self.result_rx.lock().await; + rx.recv().await + } + + /// Try to receive a mining result without blocking. + pub async fn try_recv_result(&self) -> Option { + let mut rx = self.result_rx.lock().await; + rx.try_recv().ok() + } + + /// Wait for a mining result with a timeout. + pub async fn recv_result_timeout(&self, timeout: Duration) -> Option { + let mut rx = self.result_rx.lock().await; + tokio::time::timeout(timeout, rx.recv()).await.ok().flatten() + } + + /// Check if any miners are currently connected. + pub async fn has_miners(&self) -> bool { + !self.miners.read().await.is_empty() + } + + /// Get the number of connected miners. + pub async fn miner_count(&self) -> usize { + self.miners.read().await.len() + } + + /// Add a new miner connection. + async fn add_miner(&self, job_tx: mpsc::Sender) -> u64 { + let id = self.next_miner_id.fetch_add(1, Ordering::Relaxed); + let handle = MinerHandle { job_tx }; + + self.miners.write().await.insert(id, handle); + + log::info!("⛏️ Miner {} connected (total: {})", id, self.miners.read().await.len()); + + id + } + + /// Remove a miner connection. + async fn remove_miner(&self, id: u64) { + self.miners.write().await.remove(&id); + log::info!("⛏️ Miner {} disconnected (total: {})", id, self.miners.read().await.len()); + } + + /// Get the current job (if any) for newly connecting miners. + async fn get_current_job(&self) -> Option { + self.current_job.read().await.clone() + } +} + +/// Create a QUIC server endpoint with self-signed certificate. +async fn create_server_endpoint(port: u16) -> Result { + // Generate self-signed certificate + let cert = rcgen::generate_simple_self_signed(vec!["localhost".to_string()]) + .map_err(|e| format!("Failed to generate certificate: {}", e))?; + + let cert_der = cert + .serialize_der() + .map_err(|e| format!("Failed to serialize certificate: {}", e))?; + let key_der = cert.serialize_private_key_der(); + + let cert_chain = vec![rustls::Certificate(cert_der)]; + let key = rustls::PrivateKey(key_der); + + // Create server config + let mut server_config = rustls::ServerConfig::builder() + .with_safe_defaults() + .with_no_client_auth() + .with_single_cert(cert_chain, key) + .map_err(|e| format!("Failed to create server config: {}", e))?; + + // Set ALPN protocol + server_config.alpn_protocols = vec![b"quantus-miner".to_vec()]; + + let mut quinn_config = quinn::ServerConfig::with_crypto(Arc::new(server_config)); + + // Set transport config + let mut transport_config = quinn::TransportConfig::default(); + transport_config.keep_alive_interval(Some(Duration::from_secs(10))); + transport_config.max_idle_timeout(Some(Duration::from_secs(60).try_into().unwrap())); + quinn_config.transport_config(Arc::new(transport_config)); + + // Create endpoint + let addr = format!("0.0.0.0:{}", port).parse().unwrap(); + let endpoint = quinn::Endpoint::server(quinn_config, addr) + .map_err(|e| format!("Failed to create server endpoint: {}", e))?; + + Ok(endpoint) +} + +/// Background task that accepts incoming miner connections. +async fn acceptor_task(endpoint: quinn::Endpoint, server: Arc) { + log::debug!("Acceptor task started"); + + while let Some(connecting) = endpoint.accept().await { + let server = server.clone(); + + tokio::spawn(async move { + match connecting.await { + Ok(connection) => { + log::debug!("New QUIC connection from {:?}", connection.remote_address()); + handle_miner_connection(connection, server).await; + }, + Err(e) => { + log::warn!("Failed to accept connection: {}", e); + }, + } + }); + } + + log::info!("Acceptor task stopped"); +} + +/// Handle a single miner connection. +async fn handle_miner_connection(connection: quinn::Connection, server: Arc) { + let addr = connection.remote_address(); + log::info!("⛏️ New miner connection from {}", addr); + log::debug!("Waiting for miner {} to open bidirectional stream...", addr); + + // Accept bidirectional stream from miner + let (send, recv) = match connection.accept_bi().await { + Ok(streams) => { + log::info!("⛏️ Stream accepted from miner {}", addr); + streams + }, + Err(e) => { + log::warn!("Failed to accept stream from {}: {}", addr, e); + return; + }, + }; + + // Create channel for sending jobs to this miner + let (job_tx, job_rx) = mpsc::channel::(16); + + // Register miner + let miner_id = server.add_miner(job_tx).await; + + // Send current job if there is one + if let Some(job) = server.get_current_job().await { + log::debug!("Sending current job {} to newly connected miner {}", job.job_id, miner_id); + // We'll send it through the connection handler below + } + + // Handle the connection + let result = connection_handler( + miner_id, + send, + recv, + job_rx, + server.result_tx.clone(), + server.get_current_job().await, + ) + .await; + + if let Err(e) = result { + log::debug!("Miner {} connection ended: {}", miner_id, e); + } + + // Unregister miner + server.remove_miner(miner_id).await; +} + +/// Handle communication with a single miner. +async fn connection_handler( + miner_id: u64, + mut send: quinn::SendStream, + mut recv: quinn::RecvStream, + mut job_rx: mpsc::Receiver, + result_tx: mpsc::Sender, + initial_job: Option, +) -> Result<(), String> { + // Wait for Ready message from miner (required to establish the stream) + log::debug!("Waiting for Ready message from miner {}...", miner_id); + match read_message(&mut recv).await { + Ok(MinerMessage::Ready) => { + log::debug!("Received Ready from miner {}", miner_id); + }, + Ok(other) => { + log::warn!("Expected Ready from miner {}, got {:?}", miner_id, other); + return Err("Protocol error: expected Ready message".to_string()); + }, + Err(e) => { + return Err(format!("Failed to read Ready message: {}", e)); + }, + } + + // Send initial job if there is one + if let Some(job) = initial_job { + log::debug!("Sending initial job {} to miner {}", job.job_id, miner_id); + let msg = MinerMessage::NewJob(job); + write_message(&mut send, &msg) + .await + .map_err(|e| format!("Failed to send initial job: {}", e))?; + } + + loop { + tokio::select! { + // Prioritize reading to detect disconnection faster + biased; + + // Receive results from miner + msg_result = read_message(&mut recv) => { + match msg_result { + Ok(MinerMessage::JobResult(result)) => { + log::info!( + "Received result from miner {}: job_id={}, status={:?}", + miner_id, + result.job_id, + result.status + ); + if result_tx.send(result).await.is_err() { + return Err("Result channel closed".to_string()); + } + } + Ok(MinerMessage::Ready) => { + log::debug!("Ignoring duplicate Ready from miner {}", miner_id); + } + Ok(MinerMessage::NewJob(_)) => { + log::warn!("Received unexpected NewJob from miner {}", miner_id); + } + Err(e) => { + if e.kind() == std::io::ErrorKind::UnexpectedEof { + return Err("Miner disconnected".to_string()); + } + return Err(format!("Read error: {}", e)); + } + } + } + + // Send jobs to miner + job = job_rx.recv() => { + match job { + Some(job) => { + log::debug!("Sending job {} to miner {}", job.job_id, miner_id); + let msg = MinerMessage::NewJob(job); + if let Err(e) = write_message(&mut send, &msg).await { + return Err(format!("Failed to send job: {}", e)); + } + } + None => { + // Channel closed, shut down + return Ok(()); + } + } + } + } + } +} diff --git a/node/src/service.rs b/node/src/service.rs index d46e3f7c..56a35a09 100644 --- a/node/src/service.rs +++ b/node/src/service.rs @@ -10,17 +10,17 @@ use sc_transaction_pool_api::{InPoolTransaction, OffchainTransactionPoolFactory, use sp_inherents::CreateInherentDataProviders; use tokio_util::sync::CancellationToken; -use crate::{external_miner_client::QuicMinerClient, prometheus::ResonanceBusinessMetrics}; +use crate::{miner_server::MinerServer, prometheus::ResonanceBusinessMetrics}; use codec::Encode; use jsonrpsee::tokio; use qpow_math::mine_range; -use quantus_miner_api::{ApiResponseStatus, MiningResult}; +use quantus_miner_api::{ApiResponseStatus, MiningRequest, MiningResult}; use sc_cli::TransactionPoolType; use sc_transaction_pool::TransactionPoolOptions; use sp_api::ProvideRuntimeApi; use sp_consensus::SyncOracle; use sp_consensus_qpow::QPoWApi; -use sp_core::{crypto::AccountId32, H256, U512}; +use sp_core::{crypto::AccountId32, U512}; use std::{sync::Arc, time::Duration}; use uuid::Uuid; @@ -66,27 +66,39 @@ fn parse_mining_result(result: &MiningResult, expected_job_id: &str) -> Option( - miner: &QuicMinerClient, + server: &Arc, job_id: &str, should_stop: F, ) -> Option> where F: Fn() -> bool, { + let start = std::time::Instant::now(); + loop { if should_stop() { return None; } - match miner.recv_result_timeout(Duration::from_millis(500)).await { + // Fallback timeout + if start.elapsed() > MAX_MINING_WAIT { + log::warn!("⛏️ Timed out waiting for mining result, will retry"); + return None; + } + + match server.recv_result_timeout(Duration::from_millis(500)).await { Some(result) => { if let Some(seal) = parse_mining_result(&result, job_id) { return Some(seal); @@ -229,7 +241,7 @@ pub fn new_full< >( config: Configuration, rewards_address: AccountId32, - external_miner_addr: Option, + miner_listen_port: Option, enable_peer_sharing: bool, ) -> Result { let sc_service::PartialComponents { @@ -379,41 +391,43 @@ pub fn new_full< task_manager.spawn_essential_handle().spawn("qpow-mining", None, async move { log::info!("⛏️ QPoW Mining task spawned"); let mut nonce: U512 = U512::one(); - let mut current_job_id: Option = None; - - // Connect to external miner if address is provided - let miner_client: Option = if let Some(ref addr_str) = external_miner_addr { - match addr_str.parse::() { - Ok(addr) => { - match QuicMinerClient::connect(addr).await { - Ok(client) => { - log::info!("⛏️ Connected to external miner at {}", addr); - Some(client) - }, - Err(e) => { - log::error!("⛏️ Failed to connect to external miner at {}: {}", addr, e); - None - } - } - }, + + // Start miner server if port is specified + let miner_server: Option> = if let Some(port) = miner_listen_port { + match MinerServer::start(port).await { + Ok(server) => Some(server), Err(e) => { - log::error!("⛏️ Invalid external miner address '{}': {}", addr_str, e); + log::error!("⛏️ Failed to start miner server on port {}: {}", port, e); None - } + }, } } else { None }; - // Submit new mining job + // If using external miners, wait for at least one to connect (once) + if let Some(ref server) = miner_server { + if !server.has_miners().await { + log::info!( + "⛏️ Waiting for miners to connect on port {}...", + miner_listen_port.unwrap() + ); + while !server.has_miners().await { + if mining_cancellation_token.is_cancelled() { + log::info!("⛏️ QPoW Mining task shutting down gracefully"); + return; + } + tokio::time::sleep(Duration::from_secs(1)).await; + } + } + } + let mut mining_start_time = std::time::Instant::now(); - log::info!("Mining start time: {:?}", mining_start_time); loop { // Check for cancellation if mining_cancellation_token.is_cancelled() { log::info!("⛏️ QPoW Mining task shutting down gracefully"); - // QUIC client will clean up on drop (connection closes, miner cancels job) break; } @@ -441,115 +455,119 @@ pub fn new_full< }; let version = worker_handle.version(); - // If external miner is connected, use external mining - if let Some(ref miner) = miner_client { - // Get difficulty from runtime - let difficulty = match client.runtime_api().get_difficulty(metadata.best_hash) { - Ok(d) => d, - Err(e) => { - log::warn!("⛏️ Failed to get difficulty: {:?}", e); - tokio::select! { - _ = tokio::time::sleep(Duration::from_millis(250)) => {}, - _ = mining_cancellation_token.cancelled() => continue, + // If miner server is running and has connected miners, use external mining + if let Some(ref server) = miner_server { + if server.has_miners().await { + // Get difficulty from runtime + let difficulty = + match client.runtime_api().get_difficulty(metadata.best_hash) { + Ok(d) => d, + Err(e) => { + log::warn!("⛏️ Failed to get difficulty: {:?}", e); + tokio::select! { + _ = tokio::time::sleep(Duration::from_millis(250)) => {}, + _ = mining_cancellation_token.cancelled() => continue, + } + continue; + }, + }; + + // Broadcast job to all connected miners + let job_id = Uuid::new_v4().to_string(); + let job = MiningRequest { + job_id: job_id.clone(), + mining_hash: hex::encode(metadata.pre_hash.as_bytes()), + distance_threshold: difficulty.to_string(), + }; + + server.broadcast_job(job).await; + + // Wait for result from any miner + let best_hash = metadata.best_hash; + let outcome = wait_for_mining_result(server, &job_id, || { + // Stop if new block arrived or shutdown requested + mining_cancellation_token.is_cancelled() || + worker_handle + .metadata() + .map(|m| m.best_hash != best_hash) + .unwrap_or(false) + }) + .await; + + if let Some(seal) = outcome { + let current_version = worker_handle.version(); + if current_version != version { + log::debug!(target: "miner", "Work from external miner is stale, discarding."); + } else if futures::executor::block_on(worker_handle.submit(seal)) { + let mining_time = mining_start_time.elapsed().as_secs(); + log::info!( + "🥇 Successfully mined and submitted a new block via external miner (mining time: {}s)", + mining_time + ); + nonce = U512::one(); + mining_start_time = std::time::Instant::now(); + } else { + log::warn!("⛏️ Failed to submit mined block from external miner"); + nonce += U512::one(); } - continue; - }, - }; - - // Submit job to external miner - let job_id = Uuid::new_v4().to_string(); - if let Err(e) = miner - .send_job(&job_id, &metadata.pre_hash, difficulty, nonce, U512::max_value()) - .await - { - log::warn!("⛏️ Failed to submit mining job: {}", e); - tokio::select! { - _ = tokio::time::sleep(Duration::from_millis(250)) => {}, - _ = mining_cancellation_token.cancelled() => continue, } continue; } + // No miners connected, fall through to wait + tokio::time::sleep(Duration::from_secs(1)).await; + continue; + } - // Wait for result - let best_hash = metadata.best_hash; - let outcome = wait_for_mining_result(miner, &job_id, || { - // Stop if new block arrived or shutdown requested - mining_cancellation_token.is_cancelled() - || worker_handle - .metadata() - .map(|m| m.best_hash != best_hash) - .unwrap_or(false) - }) - .await; - - if let Some(seal) = outcome { - let current_version = worker_handle.version(); - if current_version != version { - log::debug!(target: "miner", "Work from external miner is stale, discarding."); - } else if futures::executor::block_on(worker_handle.submit(seal)) { - let mining_time = mining_start_time.elapsed().as_secs(); - log::info!( - "🥇 Successfully mined and submitted a new block via external miner (mining time: {}s)", - mining_time - ); - nonce = U512::one(); - mining_start_time = std::time::Instant::now(); - } else { - log::warn!("⛏️ Failed to submit mined block from external miner"); - nonce += U512::one(); - } - } + // Local mining: try a range of N sequential nonces using optimized path + let block_hash = metadata.pre_hash.0; // [u8;32] + let start_nonce_bytes = nonce.to_big_endian(); + let difficulty = + client.runtime_api().get_difficulty(metadata.best_hash).unwrap_or_else(|e| { + log::warn!("API error getting difficulty: {:?}", e); + U512::zero() + }); + let nonces_to_mine = 300u64; + + let found = match tokio::task::spawn_blocking(move || { + mine_range(block_hash, start_nonce_bytes, nonces_to_mine, difficulty) + }) + .await + { + Ok(res) => res, + Err(e) => { + log::warn!("⛏️Local mining task failed: {}", e); + None + }, + }; + + let nonce_bytes = if let Some((good_nonce, _distance)) = found { + good_nonce } else { - // Local mining: try a range of N sequential nonces using optimized path - let block_hash = metadata.pre_hash.0; // [u8;32] - let start_nonce_bytes = nonce.to_big_endian(); - let difficulty = client - .runtime_api() - .get_difficulty(metadata.best_hash) - .unwrap_or_else(|e| { - log::warn!("API error getting difficulty: {:?}", e); - U512::zero() - }); - let nonces_to_mine = 300u64; - - let found = match tokio::task::spawn_blocking(move || { - mine_range(block_hash, start_nonce_bytes, nonces_to_mine, difficulty) - }) - .await - { - Ok(res) => res, - Err(e) => { - log::warn!("⛏️Local mining task failed: {}", e); - None - }, - }; - - let nonce_bytes = if let Some((good_nonce, _distance)) = found { - good_nonce + nonce += U512::from(nonces_to_mine); + // Yield back to the runtime to avoid starving other tasks + tokio::task::yield_now().await; + continue; + }; + + let current_version = worker_handle.version(); + // TODO: what does this check do? + if current_version == version { + if futures::executor::block_on(worker_handle.submit(nonce_bytes.encode())) { + let mining_time = mining_start_time.elapsed().as_secs(); + log::info!( + "🥇 Successfully mined and submitted a new block (mining time: {}s)", + mining_time + ); + nonce = U512::one(); + mining_start_time = std::time::Instant::now(); } else { - nonce += U512::from(nonces_to_mine); - // Yield back to the runtime to avoid starving other tasks - tokio::task::yield_now().await; - continue; - }; - - let current_version = worker_handle.version(); - // TODO: what does this check do? - if current_version == version { - if futures::executor::block_on(worker_handle.submit(nonce_bytes.encode())) { - let mining_time = mining_start_time.elapsed().as_secs(); - log::info!("🥇 Successfully mined and submitted a new block (mining time: {}s)", mining_time); - nonce = U512::one(); - mining_start_time = std::time::Instant::now(); - } else { - log::warn!("⛏️Failed to submit mined block"); - nonce += U512::one(); - } + log::warn!("⛏️Failed to submit mined block"); + nonce += U512::one(); } - - // Yield after each mining batch to cooperate with other tasks - tokio::task::yield_now().await; } + + // Yield after each mining batch to cooperate with other tasks + tokio::task::yield_now().await; } log::info!("⛏️ QPoW Mining task terminated"); From f97d1503aff386344c5222906ed752f526d3bb8d Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 27 Jan 2026 10:05:18 +0800 Subject: [PATCH 05/13] simplify loop further --- node/src/miner_server.rs | 24 ------- node/src/service.rs | 141 +++++++++++++++------------------------ 2 files changed, 55 insertions(+), 110 deletions(-) diff --git a/node/src/miner_server.rs b/node/src/miner_server.rs index b8594e4f..3268d0ba 100644 --- a/node/src/miner_server.rs +++ b/node/src/miner_server.rs @@ -116,36 +116,12 @@ impl MinerServer { } } - /// Wait for a mining result from any miner. - /// - /// Returns `None` if the channel is closed. - pub async fn recv_result(&self) -> Option { - let mut rx = self.result_rx.lock().await; - rx.recv().await - } - - /// Try to receive a mining result without blocking. - pub async fn try_recv_result(&self) -> Option { - let mut rx = self.result_rx.lock().await; - rx.try_recv().ok() - } - /// Wait for a mining result with a timeout. pub async fn recv_result_timeout(&self, timeout: Duration) -> Option { let mut rx = self.result_rx.lock().await; tokio::time::timeout(timeout, rx.recv()).await.ok().flatten() } - /// Check if any miners are currently connected. - pub async fn has_miners(&self) -> bool { - !self.miners.read().await.is_empty() - } - - /// Get the number of connected miners. - pub async fn miner_count(&self) -> usize { - self.miners.read().await.len() - } - /// Add a new miner connection. async fn add_miner(&self, job_tx: mpsc::Sender) -> u64 { let id = self.next_miner_id.fetch_add(1, Ordering::Relaxed); diff --git a/node/src/service.rs b/node/src/service.rs index 56a35a09..6925f0d0 100644 --- a/node/src/service.rs +++ b/node/src/service.rs @@ -66,17 +66,16 @@ fn parse_mining_result(result: &MiningResult, expected_job_id: &str) -> Option( server: &Arc, job_id: &str, @@ -85,19 +84,11 @@ async fn wait_for_mining_result( where F: Fn() -> bool, { - let start = std::time::Instant::now(); - loop { if should_stop() { return None; } - // Fallback timeout - if start.elapsed() > MAX_MINING_WAIT { - log::warn!("⛏️ Timed out waiting for mining result, will retry"); - return None; - } - match server.recv_result_timeout(Duration::from_millis(500)).await { Some(result) => { if let Some(seal) = parse_mining_result(&result, job_id) { @@ -405,23 +396,6 @@ pub fn new_full< None }; - // If using external miners, wait for at least one to connect (once) - if let Some(ref server) = miner_server { - if !server.has_miners().await { - log::info!( - "⛏️ Waiting for miners to connect on port {}...", - miner_listen_port.unwrap() - ); - while !server.has_miners().await { - if mining_cancellation_token.is_cancelled() { - log::info!("⛏️ QPoW Mining task shutting down gracefully"); - return; - } - tokio::time::sleep(Duration::from_secs(1)).await; - } - } - } - let mut mining_start_time = std::time::Instant::now(); loop { @@ -455,66 +429,61 @@ pub fn new_full< }; let version = worker_handle.version(); - // If miner server is running and has connected miners, use external mining + // If miner server is running, use external mining + // (broadcast_job stores the job for newly connecting miners) if let Some(ref server) = miner_server { - if server.has_miners().await { - // Get difficulty from runtime - let difficulty = - match client.runtime_api().get_difficulty(metadata.best_hash) { - Ok(d) => d, - Err(e) => { - log::warn!("⛏️ Failed to get difficulty: {:?}", e); - tokio::select! { - _ = tokio::time::sleep(Duration::from_millis(250)) => {}, - _ = mining_cancellation_token.cancelled() => continue, - } - continue; - }, - }; - - // Broadcast job to all connected miners - let job_id = Uuid::new_v4().to_string(); - let job = MiningRequest { - job_id: job_id.clone(), - mining_hash: hex::encode(metadata.pre_hash.as_bytes()), - distance_threshold: difficulty.to_string(), - }; - - server.broadcast_job(job).await; - - // Wait for result from any miner - let best_hash = metadata.best_hash; - let outcome = wait_for_mining_result(server, &job_id, || { - // Stop if new block arrived or shutdown requested - mining_cancellation_token.is_cancelled() || - worker_handle - .metadata() - .map(|m| m.best_hash != best_hash) - .unwrap_or(false) - }) - .await; - - if let Some(seal) = outcome { - let current_version = worker_handle.version(); - if current_version != version { - log::debug!(target: "miner", "Work from external miner is stale, discarding."); - } else if futures::executor::block_on(worker_handle.submit(seal)) { - let mining_time = mining_start_time.elapsed().as_secs(); - log::info!( - "🥇 Successfully mined and submitted a new block via external miner (mining time: {}s)", - mining_time - ); - nonce = U512::one(); - mining_start_time = std::time::Instant::now(); - } else { - log::warn!("⛏️ Failed to submit mined block from external miner"); - nonce += U512::one(); + // Get difficulty from runtime + let difficulty = match client.runtime_api().get_difficulty(metadata.best_hash) { + Ok(d) => d, + Err(e) => { + log::warn!("⛏️ Failed to get difficulty: {:?}", e); + tokio::select! { + _ = tokio::time::sleep(Duration::from_millis(250)) => {}, + _ = mining_cancellation_token.cancelled() => continue, } + continue; + }, + }; + + // Broadcast job to all connected miners (also stores for new miners) + let job_id = Uuid::new_v4().to_string(); + let job = MiningRequest { + job_id: job_id.clone(), + mining_hash: hex::encode(metadata.pre_hash.as_bytes()), + distance_threshold: difficulty.to_string(), + }; + + server.broadcast_job(job).await; + + // Wait for result from any miner (new miners auto-receive current job) + let best_hash = metadata.best_hash; + let outcome = wait_for_mining_result(server, &job_id, || { + // Stop if new block arrived or shutdown requested + mining_cancellation_token.is_cancelled() || + worker_handle + .metadata() + .map(|m| m.best_hash != best_hash) + .unwrap_or(false) + }) + .await; + + if let Some(seal) = outcome { + let current_version = worker_handle.version(); + if current_version != version { + log::debug!(target: "miner", "Work from external miner is stale, discarding."); + } else if futures::executor::block_on(worker_handle.submit(seal)) { + let mining_time = mining_start_time.elapsed().as_secs(); + log::info!( + "🥇 Successfully mined and submitted a new block via external miner (mining time: {}s)", + mining_time + ); + nonce = U512::one(); + mining_start_time = std::time::Instant::now(); + } else { + log::warn!("⛏️ Failed to submit mined block from external miner"); + nonce += U512::one(); } - continue; } - // No miners connected, fall through to wait - tokio::time::sleep(Duration::from_secs(1)).await; continue; } From ba656b2d99a44298b94c1830fb105de078825165 Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 27 Jan 2026 13:18:04 +0800 Subject: [PATCH 06/13] short job counters --- node/src/service.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/node/src/service.rs b/node/src/service.rs index 6925f0d0..202c9614 100644 --- a/node/src/service.rs +++ b/node/src/service.rs @@ -22,7 +22,6 @@ use sp_consensus::SyncOracle; use sp_consensus_qpow::QPoWApi; use sp_core::{crypto::AccountId32, U512}; use std::{sync::Arc, time::Duration}; -use uuid::Uuid; /// Frequency of block import logging. Every 1000 blocks. const LOG_FREQUENCY: u64 = 1000; @@ -397,6 +396,7 @@ pub fn new_full< }; let mut mining_start_time = std::time::Instant::now(); + let mut job_counter: u64 = 0; loop { // Check for cancellation @@ -446,7 +446,8 @@ pub fn new_full< }; // Broadcast job to all connected miners (also stores for new miners) - let job_id = Uuid::new_v4().to_string(); + job_counter += 1; + let job_id = job_counter.to_string(); let job = MiningRequest { job_id: job_id.clone(), mining_hash: hex::encode(metadata.pre_hash.as_bytes()), From c0d36d133794f0e2843c30ab08df97076249cfdd Mon Sep 17 00:00:00 2001 From: illuzen Date: Wed, 28 Jan 2026 12:30:23 +0800 Subject: [PATCH 07/13] simplify new_full --- Cargo.lock | 1 + Cargo.toml | 1 + node/Cargo.toml | 3 + node/src/prometheus.rs | 4 +- node/src/service.rs | 633 ++++++++++++++++++++++++++--------------- qpow-math/src/lib.rs | 43 --- 6 files changed, 408 insertions(+), 277 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 356d6ed5..e96443a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9102,6 +9102,7 @@ dependencies = [ "sc-consensus-qpow", "sc-executor", "sc-network", + "sc-network-sync", "sc-offchain", "sc-service", "sc-telemetry", diff --git a/Cargo.toml b/Cargo.toml index bd8bcfd9..bcfd00c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -193,6 +193,7 @@ sc-consensus = { version = "0.50.0", default-features = false } sc-executor = { version = "0.43.0", default-features = false } sc-network = { version = "0.51.0", default-features = false } sc-network-common = { version = "0.49.0", default-features = false } +sc-network-sync = { version = "0.50.0", default-features = false } sc-network-types = { version = "0.17.0", default-features = false } sc-offchain = { version = "46.0.0", default-features = false } sc-service = { version = "0.52.0", default-features = false } diff --git a/node/Cargo.toml b/node/Cargo.toml index b1b6af7d..f6a0db40 100644 --- a/node/Cargo.toml +++ b/node/Cargo.toml @@ -58,6 +58,8 @@ sc-executor.default-features = true sc-executor.workspace = true sc-network.default-features = true sc-network.workspace = true +sc-network-sync.default-features = true +sc-network-sync.workspace = true sc-offchain.default-features = true sc-offchain.workspace = true sc-service.default-features = true @@ -105,6 +107,7 @@ substrate-build-script-utils.workspace = true [features] default = ["std"] +tx-logging = [] # Enable transaction pool logging for debugging std = [ "codec/std", "hex/std", diff --git a/node/src/prometheus.rs b/node/src/prometheus.rs index e5d834b2..d8e6273d 100644 --- a/node/src/prometheus.rs +++ b/node/src/prometheus.rs @@ -7,9 +7,9 @@ use sp_consensus_qpow::QPoWApi; use sp_core::U512; use std::sync::Arc; -pub struct ResonanceBusinessMetrics; +pub struct BusinessMetrics; -impl ResonanceBusinessMetrics { +impl BusinessMetrics { /// Pack a U512 into an f64 by taking the highest-order 64 bits (8 bytes). fn pack_u512_to_f64(value: U512) -> f64 { // Convert U512 to big-endian bytes (64 bytes) diff --git a/node/src/service.rs b/node/src/service.rs index 202c9614..97bed677 100644 --- a/node/src/service.rs +++ b/node/src/service.rs @@ -1,27 +1,38 @@ //! Service and ServiceFactory implementation. Specialized wrapper over substrate service. - -use futures::{FutureExt, StreamExt}; +//! +//! This module provides the main service setup for a Quantus node, including: +//! - Network configuration and setup +//! - Transaction pool management +//! - Mining infrastructure (local and external miner support) +//! - RPC endpoint configuration + +use futures::FutureExt; +#[cfg(feature = "tx-logging")] +use futures::StreamExt; use quantus_runtime::{self, apis::RuntimeApi, opaque::Block}; use sc_client_api::Backend; -use sc_consensus_qpow::ChainManagement; +use sc_consensus_qpow::{ChainManagement, MiningHandle}; use sc_service::{error::Error as ServiceError, Configuration, TaskManager}; use sc_telemetry::{Telemetry, TelemetryWorker}; -use sc_transaction_pool_api::{InPoolTransaction, OffchainTransactionPoolFactory, TransactionPool}; +#[cfg(feature = "tx-logging")] +use sc_transaction_pool_api::InPoolTransaction; +use sc_transaction_pool_api::{OffchainTransactionPoolFactory, TransactionPool}; use sp_inherents::CreateInherentDataProviders; use tokio_util::sync::CancellationToken; -use crate::{miner_server::MinerServer, prometheus::ResonanceBusinessMetrics}; +use crate::{miner_server::MinerServer, prometheus::BusinessMetrics}; use codec::Encode; use jsonrpsee::tokio; -use qpow_math::mine_range; use quantus_miner_api::{ApiResponseStatus, MiningRequest, MiningResult}; +use sc_basic_authorship::ProposerFactory; use sc_cli::TransactionPoolType; use sc_transaction_pool::TransactionPoolOptions; use sp_api::ProvideRuntimeApi; use sp_consensus::SyncOracle; use sp_consensus_qpow::QPoWApi; use sp_core::{crypto::AccountId32, U512}; -use std::{sync::Arc, time::Duration}; +use std::sync::Arc; +use std::time::Duration; /// Frequency of block import logging. Every 1000 blocks. const LOG_FREQUENCY: u64 = 1000; @@ -106,6 +117,366 @@ where } } +// ============================================================================ +// Mining Loop Helpers +// ============================================================================ + +/// Result of attempting to mine with an external miner. +enum ExternalMiningOutcome { + /// Successfully found a seal. + Found(Vec), + /// Mining was interrupted (new block, cancellation, or failure). + Interrupted, +} + +/// Handle a single round of external mining. +/// +/// Broadcasts the job to connected miners and waits for a result. +/// Returns the seal if found, or indicates interruption. +async fn handle_external_mining( + server: &Arc, + client: &Arc, + worker_handle: &MiningHandle< + Block, + FullClient, + Arc>, + (), + >, + cancellation_token: &CancellationToken, + job_counter: &mut u64, +) -> ExternalMiningOutcome { + let metadata = match worker_handle.metadata() { + Some(m) => m, + None => return ExternalMiningOutcome::Interrupted, + }; + let version = worker_handle.version(); + + // Get difficulty from runtime + let difficulty = match client.runtime_api().get_difficulty(metadata.best_hash) { + Ok(d) => d, + Err(e) => { + log::warn!("⛏️ Failed to get difficulty: {:?}", e); + return ExternalMiningOutcome::Interrupted; + }, + }; + + // Create and broadcast job + *job_counter += 1; + let job_id = job_counter.to_string(); + let job = MiningRequest { + job_id: job_id.clone(), + mining_hash: hex::encode(metadata.pre_hash.as_bytes()), + distance_threshold: difficulty.to_string(), + }; + + server.broadcast_job(job).await; + + // Wait for result from any miner + let best_hash = metadata.best_hash; + let outcome = wait_for_mining_result(server, &job_id, || { + cancellation_token.is_cancelled() + || worker_handle.metadata().map(|m| m.best_hash != best_hash).unwrap_or(false) + }) + .await; + + match outcome { + Some(seal) => { + // Verify work is still valid + let current_version = worker_handle.version(); + if current_version != version { + log::debug!(target: "miner", "Work from external miner is stale, discarding."); + ExternalMiningOutcome::Interrupted + } else { + ExternalMiningOutcome::Found(seal) + } + }, + None => ExternalMiningOutcome::Interrupted, + } +} + +/// Try to find a valid nonce for local mining. +/// +/// Tries 50k nonces from a random starting point, then yields to check for new blocks. +/// With Poseidon2 hashing this takes ~50-100ms, keeping the node responsive. +async fn handle_local_mining( + client: &Arc, + worker_handle: &MiningHandle< + Block, + FullClient, + Arc>, + (), + >, +) -> Option> { + let metadata = worker_handle.metadata()?; + let version = worker_handle.version(); + let block_hash = metadata.pre_hash.0; + let difficulty = client.runtime_api().get_difficulty(metadata.best_hash).unwrap_or_else(|e| { + log::warn!("API error getting difficulty: {:?}", e); + U512::zero() + }); + + if difficulty.is_zero() { + return None; + } + + let start_nonce = U512::from(rand::random::()); + let target = U512::MAX / difficulty; + + let found = tokio::task::spawn_blocking(move || { + let mut nonce = start_nonce; + for _ in 0..50_000 { + let nonce_bytes = nonce.to_big_endian(); + if qpow_math::get_nonce_hash(block_hash, nonce_bytes) < target { + return Some(nonce_bytes); + } + nonce = nonce.overflowing_add(U512::one()).0; + } + None + }) + .await + .ok() + .flatten(); + + found.filter(|_| worker_handle.version() == version).map(|nonce| nonce.encode()) +} + +/// Submit a mined seal to the worker handle. +/// +/// Returns `true` if submission was successful, `false` otherwise. +fn submit_mined_block( + worker_handle: &MiningHandle< + Block, + FullClient, + Arc>, + (), + >, + seal: Vec, + mining_start_time: &mut std::time::Instant, + source: &str, +) -> bool { + if futures::executor::block_on(worker_handle.submit(seal)) { + let mining_time = mining_start_time.elapsed().as_secs(); + log::info!( + "🥇 Successfully mined and submitted a new block{} (mining time: {}s)", + source, + mining_time + ); + *mining_start_time = std::time::Instant::now(); + true + } else { + log::warn!("⛏️ Failed to submit mined block{}", source); + false + } +} + +/// The main mining loop that coordinates local and external mining. +/// +/// This function runs continuously until the cancellation token is triggered. +/// It handles: +/// - Waiting for sync to complete +/// - Coordinating with external miners (if server is available) +/// - Falling back to local mining +async fn mining_loop( + client: Arc, + worker_handle: MiningHandle>, ()>, + sync_service: Arc>, + miner_server: Option>, + cancellation_token: CancellationToken, +) { + log::info!("⛏️ QPoW Mining task spawned"); + + let mut mining_start_time = std::time::Instant::now(); + let mut job_counter: u64 = 0; + + loop { + if cancellation_token.is_cancelled() { + log::info!("⛏️ QPoW Mining task shutting down gracefully"); + break; + } + + // Don't mine if we're still syncing + if sync_service.is_major_syncing() { + log::debug!(target: "pow", "Mining paused: node is still syncing with network"); + tokio::select! { + _ = tokio::time::sleep(Duration::from_secs(5)) => {} + _ = cancellation_token.cancelled() => continue + } + continue; + } + + // Wait for mining metadata to be available + if worker_handle.metadata().is_none() { + log::debug!(target: "pow", "No mining metadata available"); + tokio::select! { + _ = tokio::time::sleep(Duration::from_millis(250)) => {} + _ = cancellation_token.cancelled() => continue + } + continue; + } + + // External mining path + if let Some(ref server) = miner_server { + match handle_external_mining( + server, + &client, + &worker_handle, + &cancellation_token, + &mut job_counter, + ) + .await + { + ExternalMiningOutcome::Found(seal) => { + submit_mined_block( + &worker_handle, + seal, + &mut mining_start_time, + " via external miner", + ); + }, + ExternalMiningOutcome::Interrupted => {}, + } + continue; + } + + // Local mining path + if let Some(seal) = handle_local_mining(&client, &worker_handle).await { + submit_mined_block(&worker_handle, seal, &mut mining_start_time, ""); + } + + // Yield to let other async tasks run + tokio::task::yield_now().await; + } + + log::info!("⛏️ QPoW Mining task terminated"); +} + +/// Spawn the transaction logger task. +/// +/// This task logs transactions as they are added to the pool. +/// Only available when the `tx-logging` feature is enabled. +#[cfg(feature = "tx-logging")] +fn spawn_transaction_logger( + task_manager: &TaskManager, + transaction_pool: Arc>, + tx_stream: impl futures::Stream + Send + 'static, +) { + task_manager.spawn_handle().spawn("tx-logger", None, async move { + let tx_stream = tx_stream; + futures::pin_mut!(tx_stream); + while let Some(tx_hash) = tx_stream.next().await { + if let Some(tx) = transaction_pool.ready_transaction(&tx_hash) { + log::trace!(target: "miner", "New transaction: Hash = {:?}", tx_hash); + let extrinsic = tx.data(); + log::trace!(target: "miner", "Payload: {:?}", extrinsic); + } else { + log::warn!("⛏️ Transaction {:?} not found in pool", tx_hash); + } + } + }); +} + +/// Spawn all authority-related tasks (mining, metrics, transaction logging). +/// +/// This is only called when the node is running as an authority (block producer). +#[allow(clippy::too_many_arguments)] +fn spawn_authority_tasks( + task_manager: &mut TaskManager, + client: Arc, + transaction_pool: Arc>, + select_chain: FullSelectChain, + pow_block_import: PowBlockImport, + sync_service: Arc>, + prometheus_registry: Option, + rewards_address: AccountId32, + miner_listen_port: Option, + tx_stream_for_worker: impl futures::Stream + Send + Unpin + 'static, + #[cfg(feature = "tx-logging")] tx_stream_for_logger: impl futures::Stream + + Send + + 'static, +) { + // Create block proposer factory + let proposer = ProposerFactory::new( + task_manager.spawn_handle(), + client.clone(), + transaction_pool.clone(), + prometheus_registry.as_ref(), + None, + ); + + // Create inherent data providers + let inherent_data_providers = Box::new(move |_, _| async move { + let timestamp = sp_timestamp::InherentDataProvider::from_system_time(); + Ok(timestamp) + }) + as Box< + dyn CreateInherentDataProviders< + Block, + (), + InherentDataProviders = sp_timestamp::InherentDataProvider, + >, + >; + + // Start the mining worker (block building task) + let (worker_handle, worker_task) = sc_consensus_qpow::start_mining_worker( + Box::new(pow_block_import), + client.clone(), + select_chain, + proposer, + sync_service.clone(), + sync_service.clone(), + rewards_address, + inherent_data_providers, + tx_stream_for_worker, + Duration::from_secs(10), + ); + + task_manager + .spawn_essential_handle() + .spawn_blocking("block-producer", None, worker_task); + + // Start Prometheus business metrics monitoring + BusinessMetrics::start_monitoring_task(client.clone(), prometheus_registry, task_manager); + + // Setup graceful shutdown for mining + let mining_cancellation_token = CancellationToken::new(); + let mining_token_clone = mining_cancellation_token.clone(); + + task_manager.spawn_handle().spawn("mining-shutdown-listener", None, async move { + tokio::signal::ctrl_c().await.expect("Failed to listen for Ctrl+C"); + log::info!("🛑 Received Ctrl+C signal, shutting down qpow-mining worker"); + mining_token_clone.cancel(); + }); + + // Spawn the main mining loop + task_manager.spawn_essential_handle().spawn("qpow-mining", None, async move { + // Start miner server if port is specified + let miner_server: Option> = if let Some(port) = miner_listen_port { + match MinerServer::start(port).await { + Ok(server) => Some(server), + Err(e) => { + log::error!("⛏️ Failed to start miner server on port {}: {}", port, e); + None + }, + } + } else { + None + }; + + mining_loop(client, worker_handle, sync_service, miner_server, mining_cancellation_token) + .await; + }); + + // Spawn transaction logger (only when tx-logging feature is enabled) + #[cfg(feature = "tx-logging")] + spawn_transaction_logger(task_manager, transaction_pool, tx_stream_for_logger); + + log::info!(target: "miner", "⛏️ Pow miner spawned"); +} + +// ============================================================================ +// Type Definitions +// ============================================================================ + pub(crate) type FullClient = sc_service::TFullClient< Block, RuntimeApi, @@ -246,6 +617,7 @@ pub fn new_full< } = new_partial(&config)?; let tx_stream_for_worker = transaction_pool.clone().import_notification_stream(); + #[cfg(feature = "tx-logging")] let tx_stream_for_logger = transaction_pool.clone().import_notification_stream(); let net_config = sc_network::config::FullNetworkConfiguration::< @@ -327,236 +699,33 @@ pub fn new_full< })?; if role.is_authority() { - let proposer = sc_basic_authorship::ProposerFactory::new( - task_manager.spawn_handle(), - client.clone(), - transaction_pool.clone(), - prometheus_registry.as_ref(), - None, // lets worry about telemetry later! TODO - ); - - let inherent_data_providers = Box::new(move |_, _| async move { - let timestamp = sp_timestamp::InherentDataProvider::from_system_time(); - Ok(timestamp) - }) - as Box< - dyn CreateInherentDataProviders< - Block, - (), - InherentDataProviders = sp_timestamp::InherentDataProvider, - >, - >; - - let (worker_handle, worker_task) = sc_consensus_qpow::start_mining_worker( - Box::new(pow_block_import), - client.clone(), + #[cfg(feature = "tx-logging")] + spawn_authority_tasks( + &mut task_manager, + client, + transaction_pool, select_chain.clone(), - proposer, - sync_service.clone(), - sync_service.clone(), + pow_block_import, + sync_service, + prometheus_registry, rewards_address, - inherent_data_providers, + miner_listen_port, tx_stream_for_worker, - Duration::from_secs(10), + tx_stream_for_logger, ); - - task_manager.spawn_essential_handle().spawn_blocking("pow", None, worker_task); - - ResonanceBusinessMetrics::start_monitoring_task( - client.clone(), - prometheus_registry.clone(), - &task_manager, + #[cfg(not(feature = "tx-logging"))] + spawn_authority_tasks( + &mut task_manager, + client, + transaction_pool, + select_chain.clone(), + pow_block_import, + sync_service, + prometheus_registry, + rewards_address, + miner_listen_port, + tx_stream_for_worker, ); - - let mining_cancellation_token = CancellationToken::new(); - let mining_token_clone = mining_cancellation_token.clone(); - - // Listen for shutdown signals - task_manager.spawn_handle().spawn("mining-shutdown-listener", None, async move { - tokio::signal::ctrl_c().await.expect("Failed to listen for Ctrl+C"); - log::info!("🛑 Received Ctrl+C signal, shutting down qpow-mining worker"); - mining_token_clone.cancel(); - }); - - task_manager.spawn_essential_handle().spawn("qpow-mining", None, async move { - log::info!("⛏️ QPoW Mining task spawned"); - let mut nonce: U512 = U512::one(); - - // Start miner server if port is specified - let miner_server: Option> = if let Some(port) = miner_listen_port { - match MinerServer::start(port).await { - Ok(server) => Some(server), - Err(e) => { - log::error!("⛏️ Failed to start miner server on port {}: {}", port, e); - None - }, - } - } else { - None - }; - - let mut mining_start_time = std::time::Instant::now(); - let mut job_counter: u64 = 0; - - loop { - // Check for cancellation - if mining_cancellation_token.is_cancelled() { - log::info!("⛏️ QPoW Mining task shutting down gracefully"); - break; - } - - // Don't mine if we're still syncing - if sync_service.is_major_syncing() { - log::debug!(target: "pow", "Mining paused: node is still syncing with network"); - tokio::select! { - _ = tokio::time::sleep(Duration::from_secs(5)) => {}, - _ = mining_cancellation_token.cancelled() => continue, - } - continue; - } - - // Get mining metadata - let metadata = match worker_handle.metadata() { - Some(m) => m, - None => { - log::debug!(target: "pow", "No mining metadata available"); - tokio::select! { - _ = tokio::time::sleep(Duration::from_millis(250)) => {}, - _ = mining_cancellation_token.cancelled() => continue, - } - continue; - }, - }; - let version = worker_handle.version(); - - // If miner server is running, use external mining - // (broadcast_job stores the job for newly connecting miners) - if let Some(ref server) = miner_server { - // Get difficulty from runtime - let difficulty = match client.runtime_api().get_difficulty(metadata.best_hash) { - Ok(d) => d, - Err(e) => { - log::warn!("⛏️ Failed to get difficulty: {:?}", e); - tokio::select! { - _ = tokio::time::sleep(Duration::from_millis(250)) => {}, - _ = mining_cancellation_token.cancelled() => continue, - } - continue; - }, - }; - - // Broadcast job to all connected miners (also stores for new miners) - job_counter += 1; - let job_id = job_counter.to_string(); - let job = MiningRequest { - job_id: job_id.clone(), - mining_hash: hex::encode(metadata.pre_hash.as_bytes()), - distance_threshold: difficulty.to_string(), - }; - - server.broadcast_job(job).await; - - // Wait for result from any miner (new miners auto-receive current job) - let best_hash = metadata.best_hash; - let outcome = wait_for_mining_result(server, &job_id, || { - // Stop if new block arrived or shutdown requested - mining_cancellation_token.is_cancelled() || - worker_handle - .metadata() - .map(|m| m.best_hash != best_hash) - .unwrap_or(false) - }) - .await; - - if let Some(seal) = outcome { - let current_version = worker_handle.version(); - if current_version != version { - log::debug!(target: "miner", "Work from external miner is stale, discarding."); - } else if futures::executor::block_on(worker_handle.submit(seal)) { - let mining_time = mining_start_time.elapsed().as_secs(); - log::info!( - "🥇 Successfully mined and submitted a new block via external miner (mining time: {}s)", - mining_time - ); - nonce = U512::one(); - mining_start_time = std::time::Instant::now(); - } else { - log::warn!("⛏️ Failed to submit mined block from external miner"); - nonce += U512::one(); - } - } - continue; - } - - // Local mining: try a range of N sequential nonces using optimized path - let block_hash = metadata.pre_hash.0; // [u8;32] - let start_nonce_bytes = nonce.to_big_endian(); - let difficulty = - client.runtime_api().get_difficulty(metadata.best_hash).unwrap_or_else(|e| { - log::warn!("API error getting difficulty: {:?}", e); - U512::zero() - }); - let nonces_to_mine = 300u64; - - let found = match tokio::task::spawn_blocking(move || { - mine_range(block_hash, start_nonce_bytes, nonces_to_mine, difficulty) - }) - .await - { - Ok(res) => res, - Err(e) => { - log::warn!("⛏️Local mining task failed: {}", e); - None - }, - }; - - let nonce_bytes = if let Some((good_nonce, _distance)) = found { - good_nonce - } else { - nonce += U512::from(nonces_to_mine); - // Yield back to the runtime to avoid starving other tasks - tokio::task::yield_now().await; - continue; - }; - - let current_version = worker_handle.version(); - // TODO: what does this check do? - if current_version == version { - if futures::executor::block_on(worker_handle.submit(nonce_bytes.encode())) { - let mining_time = mining_start_time.elapsed().as_secs(); - log::info!( - "🥇 Successfully mined and submitted a new block (mining time: {}s)", - mining_time - ); - nonce = U512::one(); - mining_start_time = std::time::Instant::now(); - } else { - log::warn!("⛏️Failed to submit mined block"); - nonce += U512::one(); - } - } - - // Yield after each mining batch to cooperate with other tasks - tokio::task::yield_now().await; - } - - log::info!("⛏️ QPoW Mining task terminated"); - }); - - task_manager.spawn_handle().spawn("tx-logger", None, async move { - let mut tx_stream = tx_stream_for_logger; - while let Some(tx_hash) = tx_stream.next().await { - if let Some(tx) = transaction_pool.ready_transaction(&tx_hash) { - log::trace!(target: "miner", "New transaction: Hash = {:?}", tx_hash); - let extrinsic = tx.data(); - log::trace!(target: "miner", "Payload: {:?}", extrinsic); - } else { - log::warn!("⛏️Transaction {:?} not found in pool", tx_hash); - } - } - }); - - log::info!(target: "miner", "⛏️ Pow miner spawned"); } // Start deterministic-depth finalization task diff --git a/qpow-math/src/lib.rs b/qpow-math/src/lib.rs index 50142ee9..af2a4f1f 100644 --- a/qpow-math/src/lib.rs +++ b/qpow-math/src/lib.rs @@ -49,49 +49,6 @@ pub fn get_nonce_hash( result } -/// Mine a contiguous range of nonces using simple incremental search. -/// Returns the first valid nonce and its hash if one is found. -/// This is called during local mining -pub fn mine_range( - block_hash: [u8; 32], - start_nonce: [u8; 64], - steps: u64, - difficulty: U512, -) -> Option<([u8; 64], U512)> { - if steps == 0 { - return None; - } - - if difficulty == U512::zero() { - log::error!( - "mine_range should not be called with 0 difficulty, but was for block_hash: {:?}", - block_hash - ); - return None; - } - - let mut nonce_u = U512::from_big_endian(&start_nonce); - let max_target = U512::MAX; - let target = max_target / difficulty; - - for _ in 0..steps { - let nonce_bytes = nonce_u.to_big_endian(); - let hash_result = get_nonce_hash(block_hash, nonce_bytes); - - if hash_result < target { - log::debug!(target: "math", "💎 Local miner found nonce {:x} with hash {:x} and target {:x} and block_hash {:?}", - nonce_u.low_u32() as u16, hash_result.low_u32() as u16, - target.low_u32() as u16, hex::encode(block_hash)); - return Some((nonce_bytes, hash_result)); - } - - // Advance to next nonce - nonce_u = nonce_u.saturating_add(U512::from(1u64)); - } - - None -} - #[cfg(test)] mod tests { use super::*; From e3b30bbfce2f427d23e0e0193cec373325550a1e Mon Sep 17 00:00:00 2001 From: illuzen Date: Wed, 28 Jan 2026 13:19:54 +0800 Subject: [PATCH 08/13] gracefully handle invalid seals --- client/consensus/qpow/src/worker.rs | 35 +++++++++++ node/src/service.rs | 97 ++++++++++++++++------------- 2 files changed, 88 insertions(+), 44 deletions(-) diff --git a/client/consensus/qpow/src/worker.rs b/client/consensus/qpow/src/worker.rs index e819b8c4..ca41aa95 100644 --- a/client/consensus/qpow/src/worker.rs +++ b/client/consensus/qpow/src/worker.rs @@ -31,6 +31,7 @@ use sc_consensus::{BlockImportParams, BoxBlockImport, StateAction, StorageChange use sp_api::ProvideRuntimeApi; use sp_consensus::{BlockOrigin, Proposal}; use sp_consensus_pow::{Seal, POW_ENGINE_ID}; +use sp_consensus_qpow::QPoWApi; use sp_runtime::{ traits::{Block as BlockT, Header as HeaderT}, AccountId32, DigestItem, @@ -82,6 +83,7 @@ impl MiningHandle where Block: BlockT, AC: ProvideRuntimeApi, + AC::Api: QPoWApi, L: sc_consensus::JustificationSyncLink, { fn increment_version(&self) { @@ -133,6 +135,39 @@ where self.build.lock().as_ref().map(|b| b.metadata.clone()) } + /// Verify a seal without consuming the build. + /// + /// Returns `true` if the seal is valid for the current block, `false` otherwise. + /// Returns `false` if there's no current build. + pub fn verify_seal(&self, seal: &Seal) -> bool { + let build = self.build.lock(); + let build = match build.as_ref() { + Some(b) => b, + None => return false, + }; + + // Convert seal to nonce [u8; 64] + let nonce: [u8; 64] = match seal.as_slice().try_into() { + Ok(arr) => arr, + Err(_) => { + warn!(target: LOG_TARGET, "Seal does not have exactly 64 bytes"); + return false; + }, + }; + + let pre_hash = build.metadata.pre_hash.0; + let best_hash = build.metadata.best_hash; + + // Verify using runtime API + match self.client.runtime_api().verify_nonce_local_mining(best_hash, pre_hash, nonce) { + Ok(valid) => valid, + Err(e) => { + warn!(target: LOG_TARGET, "Runtime API error verifying seal: {:?}", e); + false + }, + } + } + /// Submit a mined seal. The seal will be validated again. Returns true if the submission is /// successful. #[allow(clippy::await_holding_lock)] diff --git a/node/src/service.rs b/node/src/service.rs index 97bed677..603ca735 100644 --- a/node/src/service.rs +++ b/node/src/service.rs @@ -38,7 +38,7 @@ use std::time::Duration; const LOG_FREQUENCY: u64 = 1000; // ============================================================================ -// External Mining Helper Types and Functions +// External Mining Helper Functions // ============================================================================ /// Parse a mining result and extract the seal if valid. @@ -104,11 +104,7 @@ where if let Some(seal) = parse_mining_result(&result, job_id) { return Some(seal); } - // For completed but invalid results, or failed/cancelled, stop waiting - if result.job_id == job_id { - return None; - } - // Stale result for different job, keep waiting + // Keep waiting for other miners (stale, failed, or invalid parse) }, None => { // Timeout, continue waiting @@ -123,16 +119,17 @@ where /// Result of attempting to mine with an external miner. enum ExternalMiningOutcome { - /// Successfully found a seal. - Found(Vec), + /// Successfully found and imported a seal. + Success, /// Mining was interrupted (new block, cancellation, or failure). Interrupted, } /// Handle a single round of external mining. /// -/// Broadcasts the job to connected miners and waits for a result. -/// Returns the seal if found, or indicates interruption. +/// Broadcasts the job to connected miners and waits for results. +/// If a seal fails validation, continues waiting for more seals. +/// Only returns when a seal is successfully imported, or when interrupted. async fn handle_external_mining( server: &Arc, client: &Arc, @@ -144,12 +141,12 @@ async fn handle_external_mining( >, cancellation_token: &CancellationToken, job_counter: &mut u64, + mining_start_time: &mut std::time::Instant, ) -> ExternalMiningOutcome { let metadata = match worker_handle.metadata() { Some(m) => m, None => return ExternalMiningOutcome::Interrupted, }; - let version = worker_handle.version(); // Get difficulty from runtime let difficulty = match client.runtime_api().get_difficulty(metadata.best_hash) { @@ -163,34 +160,56 @@ async fn handle_external_mining( // Create and broadcast job *job_counter += 1; let job_id = job_counter.to_string(); + let mining_hash = hex::encode(metadata.pre_hash.as_bytes()); + log::info!( + "⛏️ Broadcasting job {}: pre_hash={}, difficulty={}", + job_id, + mining_hash, + difficulty + ); let job = MiningRequest { job_id: job_id.clone(), - mining_hash: hex::encode(metadata.pre_hash.as_bytes()), + mining_hash, distance_threshold: difficulty.to_string(), }; server.broadcast_job(job).await; - // Wait for result from any miner + // Wait for results from miners, retrying on invalid seals let best_hash = metadata.best_hash; - let outcome = wait_for_mining_result(server, &job_id, || { - cancellation_token.is_cancelled() - || worker_handle.metadata().map(|m| m.best_hash != best_hash).unwrap_or(false) - }) - .await; - - match outcome { - Some(seal) => { - // Verify work is still valid - let current_version = worker_handle.version(); - if current_version != version { - log::debug!(target: "miner", "Work from external miner is stale, discarding."); - ExternalMiningOutcome::Interrupted - } else { - ExternalMiningOutcome::Found(seal) - } - }, - None => ExternalMiningOutcome::Interrupted, + loop { + let seal = match wait_for_mining_result(server, &job_id, || { + cancellation_token.is_cancelled() + || worker_handle.metadata().map(|m| m.best_hash != best_hash).unwrap_or(true) + }) + .await + { + Some(seal) => seal, + None => return ExternalMiningOutcome::Interrupted, + }; + + // Verify the seal before attempting to submit (submit consumes the build) + if !worker_handle.verify_seal(&seal) { + log::warn!( + "⛏️ Invalid seal from miner, continuing to wait for valid seals (job {})", + job_id + ); + continue; + } + + // Seal is valid, submit it + if futures::executor::block_on(worker_handle.submit(seal.clone())) { + let mining_time = mining_start_time.elapsed().as_secs(); + log::info!( + "🥇 Successfully mined and submitted a new block via external miner (mining time: {}s)", + mining_time + ); + *mining_start_time = std::time::Instant::now(); + return ExternalMiningOutcome::Success; + } + + // Submit failed for some other reason (should be rare after verify_seal passed) + log::warn!("⛏️ Failed to submit verified seal, continuing to wait (job {})", job_id); } } @@ -316,25 +335,15 @@ async fn mining_loop( // External mining path if let Some(ref server) = miner_server { - match handle_external_mining( + handle_external_mining( server, &client, &worker_handle, &cancellation_token, &mut job_counter, + &mut mining_start_time, ) - .await - { - ExternalMiningOutcome::Found(seal) => { - submit_mined_block( - &worker_handle, - seal, - &mut mining_start_time, - " via external miner", - ); - }, - ExternalMiningOutcome::Interrupted => {}, - } + .await; continue; } From a996eee3be64b5ecd55abd35051105a44a34496b Mon Sep 17 00:00:00 2001 From: illuzen Date: Wed, 28 Jan 2026 15:15:21 +0800 Subject: [PATCH 09/13] remove unused and log misbehaving miners --- Cargo.lock | 11 -------- client/cli/Cargo.toml | 2 -- client/consensus/qpow/Cargo.toml | 6 ----- client/network/Cargo.toml | 1 - miner-api/src/lib.rs | 3 +++ node/Cargo.toml | 2 -- node/src/miner_server.rs | 4 ++- node/src/service.rs | 45 +++++++++++++++++++++----------- qpow-math/Cargo.toml | 2 -- 9 files changed, 36 insertions(+), 40 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e96443a0..a5f21ded 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9037,8 +9037,6 @@ version = "0.1.0" dependencies = [ "hex", "log", - "num-bigint", - "num-traits", "primitive-types 0.13.1", "qp-poseidon-core", ] @@ -9084,7 +9082,6 @@ dependencies = [ "parity-scale-codec", "prometheus", "qp-dilithium-crypto", - "qp-rusty-crystals-dilithium", "qp-rusty-crystals-hdwallet", "qp-wormhole-circuit-builder", "qp-wormhole-verifier", @@ -9125,7 +9122,6 @@ dependencies = [ "substrate-build-script-utils", "substrate-frame-rpc-system", "tokio-util", - "uuid", ] [[package]] @@ -10162,7 +10158,6 @@ dependencies = [ "parity-scale-codec", "qp-dilithium-crypto", "qp-rusty-crystals-dilithium", - "qp-rusty-crystals-hdwallet", "rand 0.8.5", "regex", "rpassword", @@ -10181,7 +10176,6 @@ dependencies = [ "sp-blockchain", "sp-core", "sp-keyring", - "sp-keystore", "sp-panic-handler", "sp-runtime", "sp-tracing", @@ -10337,9 +10331,6 @@ dependencies = [ "sc-client-api", "sc-consensus", "sc-service", - "scale-info", - "sha2 0.10.9", - "sha3", "sp-api", "sp-block-builder", "sp-blockchain", @@ -10528,7 +10519,6 @@ dependencies = [ "log", "mockall", "multistream-select", - "once_cell", "parity-scale-codec", "parking_lot 0.12.4", "partial_sort", @@ -14125,7 +14115,6 @@ checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" dependencies = [ "getrandom 0.3.3", "js-sys", - "serde", "wasm-bindgen", ] diff --git a/client/cli/Cargo.toml b/client/cli/Cargo.toml index e730af89..23c601e1 100644 --- a/client/cli/Cargo.toml +++ b/client/cli/Cargo.toml @@ -27,7 +27,6 @@ log = { workspace = true, default-features = true } names = { workspace = true, default-features = false } qp-dilithium-crypto = { workspace = true, features = ["full_crypto", "serde", "std"] } qp-rusty-crystals-dilithium = { workspace = true } -qp-rusty-crystals-hdwallet = { workspace = true } rand = { workspace = true, default-features = true } regex = { workspace = true } rpassword = { workspace = true } @@ -46,7 +45,6 @@ serde_json = { workspace = true, default-features = true } sp-blockchain = { workspace = true, default-features = true } sp-core = { workspace = true, default-features = true } sp-keyring = { workspace = true, default-features = true } -sp-keystore = { workspace = true, default-features = true } sp-panic-handler = { workspace = true, default-features = true } sp-runtime = { workspace = true, default-features = true } sp-version = { workspace = true, default-features = true } diff --git a/client/consensus/qpow/Cargo.toml b/client/consensus/qpow/Cargo.toml index 7c9c1de2..598bef29 100644 --- a/client/consensus/qpow/Cargo.toml +++ b/client/consensus/qpow/Cargo.toml @@ -18,9 +18,6 @@ prometheus-endpoint = { workspace = true, default-features = true } sc-client-api = { workspace = true, default-features = false } sc-consensus = { workspace = true } sc-service = { workspace = true, default-features = false } -scale-info = { workspace = true, default-features = false } -sha2.workspace = true -sha3.workspace = true sp-api = { workspace = true, default-features = false } sp-block-builder = { workspace = true, default-features = true } sp-blockchain = { workspace = true, default-features = false } @@ -37,9 +34,6 @@ default = ["std"] std = [ "codec/std", "primitive-types/std", - "scale-info/std", - "sha2/std", - "sha3/std", "sp-api/std", "sp-consensus-pow/std", "sp-consensus-qpow/std", diff --git a/client/network/Cargo.toml b/client/network/Cargo.toml index 5e00f677..5616dabf 100644 --- a/client/network/Cargo.toml +++ b/client/network/Cargo.toml @@ -39,7 +39,6 @@ libp2p-identity = { workspace = true, features = ["dilithium"] } linked_hash_set = { workspace = true } log = { workspace = true, default-features = true } mockall = { workspace = true } -once_cell = { workspace = true } parking_lot = { workspace = true, default-features = true } partial_sort = { workspace = true } pin-project = { workspace = true } diff --git a/miner-api/src/lib.rs b/miner-api/src/lib.rs index d86805e6..6c15fc34 100644 --- a/miner-api/src/lib.rs +++ b/miner-api/src/lib.rs @@ -108,4 +108,7 @@ pub struct MiningResult { pub work: Option, pub hash_count: u64, pub elapsed_time: f64, + /// Miner ID assigned by the node (set server-side, not by the miner). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub miner_id: Option, } diff --git a/node/Cargo.toml b/node/Cargo.toml index f6a0db40..d6a330c3 100644 --- a/node/Cargo.toml +++ b/node/Cargo.toml @@ -33,7 +33,6 @@ pallet-transaction-payment-rpc.default-features = true pallet-transaction-payment-rpc.workspace = true prometheus.workspace = true qp-dilithium-crypto = { workspace = true } -qp-rusty-crystals-dilithium.workspace = true qp-rusty-crystals-hdwallet.workspace = true qpow-math.workspace = true quantus-miner-api = { workspace = true } @@ -97,7 +96,6 @@ sp-timestamp.workspace = true substrate-frame-rpc-system.default-features = true substrate-frame-rpc-system.workspace = true tokio-util.workspace = true -uuid.workspace = true [build-dependencies] qp-wormhole-circuit-builder.workspace = true diff --git a/node/src/miner_server.rs b/node/src/miner_server.rs index 3268d0ba..76b2c3fc 100644 --- a/node/src/miner_server.rs +++ b/node/src/miner_server.rs @@ -299,13 +299,15 @@ async fn connection_handler( // Receive results from miner msg_result = read_message(&mut recv) => { match msg_result { - Ok(MinerMessage::JobResult(result)) => { + Ok(MinerMessage::JobResult(mut result)) => { log::info!( "Received result from miner {}: job_id={}, status={:?}", miner_id, result.job_id, result.status ); + // Tag the result with the miner ID + result.miner_id = Some(miner_id); if result_tx.send(result).await.is_err() { return Err("Result channel closed".to_string()); } diff --git a/node/src/service.rs b/node/src/service.rs index 603ca735..4144cce9 100644 --- a/node/src/service.rs +++ b/node/src/service.rs @@ -43,20 +43,24 @@ const LOG_FREQUENCY: u64 = 1000; /// Parse a mining result and extract the seal if valid. fn parse_mining_result(result: &MiningResult, expected_job_id: &str) -> Option> { + let miner_id = result.miner_id.unwrap_or(0); + // Check job ID matches if result.job_id != expected_job_id { - log::debug!(target: "miner", "Received stale result for job {}, ignoring", result.job_id); + log::debug!(target: "miner", "Received stale result from miner {} for job {}, ignoring", miner_id, result.job_id); return None; } // Check status if result.status != ApiResponseStatus::Completed { match result.status { - ApiResponseStatus::Failed => log::warn!("⛏️ Mining job failed"), + ApiResponseStatus::Failed => log::warn!("⛏️ Mining job failed (miner {})", miner_id), ApiResponseStatus::Cancelled => { - log::debug!(target: "miner", "Mining job was cancelled") + log::debug!(target: "miner", "Mining job was cancelled (miner {})", miner_id) + }, + _ => { + log::debug!(target: "miner", "Unexpected result status from miner {}: {:?}", miner_id, result.status) }, - _ => log::debug!(target: "miner", "Unexpected result status: {:?}", result.status), } return None; } @@ -66,11 +70,15 @@ fn parse_mining_result(result: &MiningResult, expected_job_id: &str) -> Option Some(seal), Ok(seal) => { - log::warn!("⛏️ Invalid seal length from miner: {} bytes", seal.len()); + log::error!( + "🚨🚨🚨 INVALID SEAL LENGTH FROM MINER {}! Expected 64 bytes, got {} bytes", + miner_id, + seal.len() + ); None }, Err(e) => { - log::warn!("⛏️ Failed to decode work hex: {}", e); + log::error!("🚨🚨🚨 FAILED TO DECODE SEAL HEX FROM MINER {}: {}", miner_id, e); None }, } @@ -78,7 +86,7 @@ fn parse_mining_result(result: &MiningResult, expected_job_id: &str) -> Option( server: &Arc, job_id: &str, should_stop: F, -) -> Option> +) -> Option<(u64, Vec)> where F: Fn() -> bool, { @@ -101,8 +109,9 @@ where match server.recv_result_timeout(Duration::from_millis(500)).await { Some(result) => { + let miner_id = result.miner_id.unwrap_or(0); if let Some(seal) = parse_mining_result(&result, job_id) { - return Some(seal); + return Some((miner_id, seal)); } // Keep waiting for other miners (stale, failed, or invalid parse) }, @@ -178,20 +187,21 @@ async fn handle_external_mining( // Wait for results from miners, retrying on invalid seals let best_hash = metadata.best_hash; loop { - let seal = match wait_for_mining_result(server, &job_id, || { + let (miner_id, seal) = match wait_for_mining_result(server, &job_id, || { cancellation_token.is_cancelled() || worker_handle.metadata().map(|m| m.best_hash != best_hash).unwrap_or(true) }) .await { - Some(seal) => seal, + Some(result) => result, None => return ExternalMiningOutcome::Interrupted, }; // Verify the seal before attempting to submit (submit consumes the build) if !worker_handle.verify_seal(&seal) { - log::warn!( - "⛏️ Invalid seal from miner, continuing to wait for valid seals (job {})", + log::error!( + "🚨🚨🚨 INVALID SEAL FROM MINER {}! Job {} - seal failed verification. This may indicate a miner bug or stale work. Continuing to wait for valid seals...", + miner_id, job_id ); continue; @@ -201,7 +211,8 @@ async fn handle_external_mining( if futures::executor::block_on(worker_handle.submit(seal.clone())) { let mining_time = mining_start_time.elapsed().as_secs(); log::info!( - "🥇 Successfully mined and submitted a new block via external miner (mining time: {}s)", + "🥇 Successfully mined and submitted a new block via external miner {} (mining time: {}s)", + miner_id, mining_time ); *mining_start_time = std::time::Instant::now(); @@ -209,7 +220,11 @@ async fn handle_external_mining( } // Submit failed for some other reason (should be rare after verify_seal passed) - log::warn!("⛏️ Failed to submit verified seal, continuing to wait (job {})", job_id); + log::warn!( + "⛏️ Failed to submit verified seal from miner {}, continuing to wait (job {})", + miner_id, + job_id + ); } } diff --git a/qpow-math/Cargo.toml b/qpow-math/Cargo.toml index e241d510..2a6066bd 100644 --- a/qpow-math/Cargo.toml +++ b/qpow-math/Cargo.toml @@ -6,8 +6,6 @@ version = "0.1.0" [dependencies] hex = { workspace = true, features = ["alloc"] } log = { version = "0.4.22", default-features = false } -num-bigint = { version = "0.4", default-features = false } -num-traits = { version = "0.2", default-features = false } primitive-types = { version = "0.13.1", default-features = false } qp-poseidon-core = { workspace = true } From 2b379bcf1417ac30d2f58d8e1a5698abfecb230e Mon Sep 17 00:00:00 2001 From: illuzen Date: Wed, 28 Jan 2026 16:24:30 +0800 Subject: [PATCH 10/13] emoji --- node/src/miner_server.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/node/src/miner_server.rs b/node/src/miner_server.rs index 76b2c3fc..17951bf9 100644 --- a/node/src/miner_server.rs +++ b/node/src/miner_server.rs @@ -301,7 +301,7 @@ async fn connection_handler( match msg_result { Ok(MinerMessage::JobResult(mut result)) => { log::info!( - "Received result from miner {}: job_id={}, status={:?}", + "⛏️ Received result from miner {}: job_id={}, status={:?}", miner_id, result.job_id, result.status From 64c604389c9ceb354959b03660455a18b14122c6 Mon Sep 17 00:00:00 2001 From: illuzen Date: Wed, 28 Jan 2026 16:58:28 +0800 Subject: [PATCH 11/13] fmt --- node/src/service.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/node/src/service.rs b/node/src/service.rs index 4144cce9..ce939279 100644 --- a/node/src/service.rs +++ b/node/src/service.rs @@ -31,8 +31,7 @@ use sp_api::ProvideRuntimeApi; use sp_consensus::SyncOracle; use sp_consensus_qpow::QPoWApi; use sp_core::{crypto::AccountId32, U512}; -use std::sync::Arc; -use std::time::Duration; +use std::{sync::Arc, time::Duration}; /// Frequency of block import logging. Every 1000 blocks. const LOG_FREQUENCY: u64 = 1000; @@ -188,8 +187,8 @@ async fn handle_external_mining( let best_hash = metadata.best_hash; loop { let (miner_id, seal) = match wait_for_mining_result(server, &job_id, || { - cancellation_token.is_cancelled() - || worker_handle.metadata().map(|m| m.best_hash != best_hash).unwrap_or(true) + cancellation_token.is_cancelled() || + worker_handle.metadata().map(|m| m.best_hash != best_hash).unwrap_or(true) }) .await { From 72f12aa73a0fea18a26a155be7824465bbf428d0 Mon Sep 17 00:00:00 2001 From: illuzen Date: Wed, 28 Jan 2026 17:03:47 +0800 Subject: [PATCH 12/13] taplo --- node/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/node/Cargo.toml b/node/Cargo.toml index d6a330c3..9df4bb9e 100644 --- a/node/Cargo.toml +++ b/node/Cargo.toml @@ -105,7 +105,6 @@ substrate-build-script-utils.workspace = true [features] default = ["std"] -tx-logging = [] # Enable transaction pool logging for debugging std = [ "codec/std", "hex/std", @@ -118,6 +117,7 @@ std = [ "serde_json/std", "sp-consensus-qpow/std", ] +tx-logging = [] # Enable transaction pool logging for debugging # Dependencies that are only required if runtime benchmarking should be build. runtime-benchmarks = [ "frame-benchmarking-cli/runtime-benchmarks", From c3e572f87fc69484a0069234c245e16357b65741 Mon Sep 17 00:00:00 2001 From: illuzen Date: Thu, 29 Jan 2026 10:26:08 +0800 Subject: [PATCH 13/13] improve readability, logs, documentation --- EXTERNAL_MINER_PROTOCOL.md | 14 +++++++++++--- node/src/service.rs | 10 ++++------ 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/EXTERNAL_MINER_PROTOCOL.md b/EXTERNAL_MINER_PROTOCOL.md index 0fec0065..9c17730a 100644 --- a/EXTERNAL_MINER_PROTOCOL.md +++ b/EXTERNAL_MINER_PROTOCOL.md @@ -52,10 +52,11 @@ When multiple miners are connected: ### Message Types -The protocol uses only **two message types**: +The protocol uses **three message types**: | Direction | Message | Description | |-----------|---------|-------------| +| Miner → Node | `Ready` | Sent immediately after connecting to establish the stream | | Node → Miner | `NewJob` | Submit a mining job (implicitly cancels any previous job) | | Miner → Node | `JobResult` | Mining result (completed, failed, or cancelled) | @@ -80,8 +81,9 @@ See the `quantus-miner-api` crate for the canonical Rust definitions. ```rust pub enum MinerMessage { - NewJob(MiningRequest), - JobResult(MiningResult), + Ready, // Miner → Node: establish stream + NewJob(MiningRequest), // Node → Miner: submit job + JobResult(MiningResult), // Miner → Node: return result } ``` @@ -105,6 +107,7 @@ Note: Nonce range is not specified - each miner independently selects a random s | `work` | Option | Winning nonce as bytes (128 hex chars) | | `hash_count` | u64 | Number of nonces checked | | `elapsed_time` | f64 | Time spent mining (seconds) | +| `miner_id` | Option | Miner ID (set by node, not miner) | ### ApiResponseStatus (Enum) @@ -125,6 +128,8 @@ Miner Node │──── QUIC Connect ─────────────────────────►│ │◄─── Connection Established ────────────────│ │ │ + │──── Ready ────────────────────────────────►│ (establish stream) + │ │ │◄─── NewJob { job_id: "abc", ... } ─────────│ │ │ │ (picks random nonce, starts mining) │ @@ -166,6 +171,9 @@ Miner (new) Node │ │ (already mining job "abc") │──── QUIC Connect ─────────────────────────►│ │◄─── Connection Established ────────────────│ + │ │ + │──── Ready ────────────────────────────────►│ (establish stream) + │ │ │◄─── NewJob { job_id: "abc", ... } ─────────│ (current job sent immediately) │ │ │ (joins mining effort) │ diff --git a/node/src/service.rs b/node/src/service.rs index ce939279..9215b035 100644 --- a/node/src/service.rs +++ b/node/src/service.rs @@ -347,8 +347,8 @@ async fn mining_loop( continue; } - // External mining path if let Some(ref server) = miner_server { + // External mining path handle_external_mining( server, &client, @@ -358,11 +358,8 @@ async fn mining_loop( &mut mining_start_time, ) .await; - continue; - } - - // Local mining path - if let Some(seal) = handle_local_mining(&client, &worker_handle).await { + } else if let Some(seal) = handle_local_mining(&client, &worker_handle).await { + // Local mining path submit_mined_block(&worker_handle, seal, &mut mining_start_time, ""); } @@ -482,6 +479,7 @@ fn spawn_authority_tasks( }, } } else { + log::warn!("⚠️ No --miner-listen-port specified. Using LOCAL mining only."); None };