Skip to main content

quantus_miner_api/
lib.rs

1use std::fmt;
2
3use serde::{Deserialize, Serialize};
4use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
5
6/// Maximum message size (1 KB) to prevent memory exhaustion attacks.
7///
8/// Real MinerMessage payloads are only a few hundred bytes (Ready, NewJob, JobResult).
9/// 1 KB provides sufficient headroom while minimizing the amplification attack surface.
10pub const MAX_MESSAGE_SIZE: u32 = 1024;
11
12/// Conservative max auth token length so a `Ready { token }` JSON frame still fits
13/// under [`MAX_MESSAGE_SIZE`]. Larger operator-supplied tokens would make every
14/// miner fail with an opaque framing/deserialize error.
15pub const MAX_AUTH_TOKEN_LEN: usize = 512;
16
17/// Status codes returned in API responses.
18#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
19#[serde(rename_all = "snake_case")]
20pub enum ApiResponseStatus {
21	Accepted,
22	Running,
23	Completed,
24	Failed,
25	Cancelled,
26	NotFound,
27	Error,
28}
29
30/// QUIC protocol messages exchanged between node and miner.
31///
32/// The protocol is:
33/// - Miner sends `Ready { token }` immediately after connecting to establish the stream and
34///   authenticate (token must match the node's miner auth token)
35/// - Node sends `NewJob` to submit a mining job (implicitly cancels any previous job)
36/// - Miner sends `JobResult` when mining completes
37#[derive(Serialize, Deserialize, Clone)]
38pub enum MinerMessage {
39	/// Miner → Node: Sent immediately after connecting to establish the stream
40	/// and authenticate. This is required because QUIC streams are lazily initialized.
41	Ready {
42		/// Shared secret that must match the node's miner auth token.
43		token: String,
44	},
45
46	/// Node → Miner: Submit a new mining job.
47	/// If a job is already running, it will be cancelled and replaced.
48	NewJob(MiningRequest),
49
50	/// Miner → Node: Mining result (completed, failed, or cancelled).
51	JobResult(MiningResult),
52}
53
54impl fmt::Debug for MinerMessage {
55	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56		match self {
57			Self::Ready { .. } => f.write_str("Ready { token: \"[REDACTED]\" }"),
58			Self::NewJob(req) => f.debug_tuple("NewJob").field(req).finish(),
59			Self::JobResult(res) => f.debug_tuple("JobResult").field(res).finish(),
60		}
61	}
62}
63
64/// Write a length-prefixed JSON message to an async writer.
65///
66/// Wire format: 4-byte big-endian length prefix followed by JSON payload.
67pub async fn write_message<W: AsyncWrite + Unpin>(
68	writer: &mut W,
69	msg: &MinerMessage,
70) -> std::io::Result<()> {
71	let json = serde_json::to_vec(msg)
72		.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
73	let len = json.len() as u32;
74	writer.write_all(&len.to_be_bytes()).await?;
75	writer.write_all(&json).await?;
76	Ok(())
77}
78
79/// Read a length-prefixed JSON message from an async reader.
80///
81/// Wire format: 4-byte big-endian length prefix followed by JSON payload.
82/// Returns an error if the message exceeds MAX_MESSAGE_SIZE.
83pub async fn read_message<R: AsyncRead + Unpin>(reader: &mut R) -> std::io::Result<MinerMessage> {
84	let mut len_buf = [0u8; 4];
85	reader.read_exact(&mut len_buf).await?;
86	let len = u32::from_be_bytes(len_buf);
87
88	if len > MAX_MESSAGE_SIZE {
89		return Err(std::io::Error::new(
90			std::io::ErrorKind::InvalidData,
91			format!("Message size {} exceeds maximum {}", len, MAX_MESSAGE_SIZE),
92		));
93	}
94
95	let mut buf = vec![0u8; len as usize];
96	reader.read_exact(&mut buf).await?;
97	serde_json::from_slice(&buf)
98		.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
99}
100
101/// Request payload sent from Node to Miner.
102///
103/// The miner will choose its own random starting nonce, enabling multiple
104/// miners to work on the same job without coordination.
105#[derive(Serialize, Deserialize, Debug, Clone)]
106pub struct MiningRequest {
107	pub job_id: String,
108	/// Hex encoded header hash (32 bytes -> 64 chars, no 0x prefix)
109	pub mining_hash: String,
110	/// Difficulty (U512 as decimal string). Must be non-zero.
111	pub difficulty: String,
112}
113
114/// Response payload for job submission (`/mine`) and cancellation (`/cancel`).
115#[derive(Serialize, Deserialize, Debug, Clone)]
116pub struct MiningResponse {
117	pub status: ApiResponseStatus,
118	pub job_id: String,
119	#[serde(skip_serializing_if = "Option::is_none")]
120	pub message: Option<String>,
121}
122
123/// Response payload for checking job results (`/result/{job_id}`).
124#[derive(Serialize, Deserialize, Debug, Clone)]
125pub struct MiningResult {
126	pub status: ApiResponseStatus,
127	pub job_id: String,
128	/// Hex encoded U512 representation of the final/winning nonce (no 0x prefix).
129	pub nonce: Option<String>,
130	/// Hex encoded [u8; 64] representation of the winning nonce (128 chars, no 0x prefix).
131	/// This is the primary field the Node uses for verification.
132	pub work: Option<String>,
133	pub hash_count: u64,
134	pub elapsed_time: f64,
135	/// Miner ID assigned by the node (set server-side, not by the miner).
136	#[serde(default, skip_serializing_if = "Option::is_none")]
137	pub miner_id: Option<u64>,
138}