Parcourir la source

darkfid: Implement Stratum mining server

x il y a 7 mois
Parent
commit
cbe47456ee

+ 3 - 0
bin/darkfid/darkfid_config.toml

@@ -43,6 +43,9 @@ rpc_listen = "tcp://127.0.0.1:8340"
 # Disabled RPC methods
 rpc_disabled_methods = ["p2p.get_info"]
 
+[network_config."testnet".stratum_rpc]
+rpc_listen = "tcp://127.0.0.1:8341"
+
 ## Testnet JSON-RPC settings for p2pool merge mining requests (optional)
 #[network_config."testnet".mm_rpc]
 # JSON-RPC listen URL (merge mining)

+ 70 - 14
bin/darkfid/src/lib.rs

@@ -52,11 +52,12 @@ use error::{server_error, RpcError};
 
 /// JSON-RPC requests handler and methods
 mod rpc;
-use rpc::{DefaultRpcHandler, MmRpcHandler};
+use rpc::{DefaultRpcHandler, MmRpcHandler, StratumRpcHandler};
 mod rpc_blockchain;
 mod rpc_miner;
 mod rpc_tx;
 use rpc_miner::BlockTemplate;
+mod rpc_stratum;
 mod rpc_xmr;
 
 /// Validator async tasks
@@ -70,6 +71,25 @@ use proto::{DarkfidP2pHandler, DarkfidP2pHandlerPtr};
 /// Atomic pointer to the DarkFi node
 pub type DarkfiNodePtr = Arc<DarkfiNode>;
 
+/// Storage for active mining jobs. These are stored per connection ID.
+/// A new map will be made for each stratum login.
+#[derive(Debug, Default)]
+pub struct MiningJobs(HashMap<[u8; 32], BlockTemplate>);
+
+impl MiningJobs {
+    pub fn insert(&mut self, job_id: [u8; 32], blocktemplate: BlockTemplate) {
+        self.0.insert(job_id, blocktemplate);
+    }
+
+    pub fn get(&self, job_id: &[u8; 32]) -> Option<&BlockTemplate> {
+        self.0.get(job_id)
+    }
+
+    pub fn get_mut(&mut self, job_id: &[u8; 32]) -> Option<&mut BlockTemplate> {
+        self.0.get_mut(job_id)
+    }
+}
+
 /// Structure representing a DarkFi node
 pub struct DarkfiNode {
     /// Blockchain network
@@ -84,10 +104,14 @@ pub struct DarkfiNode {
     subscribers: HashMap<&'static str, JsonSubscriber>,
     /// Native mining block templates
     blocktemplates: Mutex<HashMap<Vec<u8>, BlockTemplate>>,
+    /// Active mining jobs per connection ID
+    mining_jobs: Mutex<HashMap<[u8; 32], MiningJobs>>,
     /// Merge mining block templates
     mm_blocktemplates: Mutex<HashMap<Vec<u8>, (BlockInfo, f64, SecretKey)>>,
-    /// JSON-RPC connection tracker
+    /// Main JSON-RPC connection tracker
     rpc_connections: Mutex<HashSet<StoppableTaskPtr>>,
+    /// Stratum JSON-RPC connection tracker
+    stratum_rpc_connections: Mutex<HashSet<StoppableTaskPtr>>,
     /// HTTP JSON-RPC connection tracker
     mm_rpc_connections: Mutex<HashSet<StoppableTaskPtr>>,
     /// PowRewardV1 ZK data
@@ -110,9 +134,11 @@ impl DarkfiNode {
             validator,
             txs_batch_size,
             subscribers,
+            mining_jobs: Mutex::new(HashMap::new()),
             blocktemplates: Mutex::new(HashMap::new()),
             mm_blocktemplates: Mutex::new(HashMap::new()),
             rpc_connections: Mutex::new(HashSet::new()),
+            stratum_rpc_connections: Mutex::new(HashSet::new()),
             mm_rpc_connections: Mutex::new(HashSet::new()),
             powrewardv1_zk,
         }))
@@ -154,8 +180,10 @@ pub struct Darkfid {
     node: DarkfiNodePtr,
     /// `dnet` background task
     dnet_task: StoppableTaskPtr,
-    /// JSON-RPC background task
+    /// Main JSON-RPC background task
     rpc_task: StoppableTaskPtr,
+    /// Stratum JSON-RPC background task
+    stratum_rpc_task: StoppableTaskPtr,
     /// HTTP JSON-RPC background task
     mm_rpc_task: StoppableTaskPtr,
     /// Consensus protocol background task
@@ -208,12 +236,20 @@ impl Darkfid {
         // Generate the background tasks
         let dnet_task = StoppableTask::new();
         let rpc_task = StoppableTask::new();
+        let stratum_rpc_task = StoppableTask::new();
         let mm_rpc_task = StoppableTask::new();
         let consensus_task = StoppableTask::new();
 
         info!(target: "darkfid::Darkfid::init", "Darkfi daemon initialized successfully!");
 
-        Ok(Arc::new(Self { node, dnet_task, rpc_task, mm_rpc_task, consensus_task }))
+        Ok(Arc::new(Self {
+            node,
+            dnet_task,
+            rpc_task,
+            stratum_rpc_task,
+            mm_rpc_task,
+            consensus_task,
+        }))
     }
 
     /// Start the DarkFi daemon in the given executor, using the provided JSON-RPC listen url
@@ -222,6 +258,7 @@ impl Darkfid {
         &self,
         executor: &ExecutorPtr,
         rpc_settings: &RpcSettings,
+        stratum_rpc_settings: &RpcSettings,
         mm_rpc_settings: &Option<RpcSettings>,
         config: &ConsensusInitTaskConfig,
     ) -> Result<()> {
@@ -250,31 +287,46 @@ impl Darkfid {
             executor.clone(),
         );
 
-        // Start the JSON-RPC task
-        info!(target: "darkfid::Darkfid::start", "Starting JSON-RPC server");
+        // Start the main JSON-RPC task
+        info!(target: "darkfid::Darkfid::start", "Starting main JSON-RPC server");
         let node_ = self.node.clone();
         self.rpc_task.clone().start(
             listen_and_serve::<DefaultRpcHandler>(rpc_settings.clone(), self.node.clone(), None, executor.clone()),
             |res| async move {
                 match res {
                     Ok(()) | Err(Error::RpcServerStopped) => <DarkfiNode as RequestHandler<DefaultRpcHandler>>::stop_connections(&node_).await,
-                    Err(e) => error!(target: "darkfid::Darkfid::start", "Failed starting JSON-RPC server: {e}"),
+                    Err(e) => error!(target: "darkfid::Darkfid::start", "Failed starting main JSON-RPC server: {e}"),
                 }
             },
             Error::RpcServerStopped,
             executor.clone(),
         );
 
-        // Start the HTTP JSON-RPC task
+        // Start the stratum server JSON-RPC task
+        info!(target: "darkfid::Darkfid::start", "Starting Stratum JSON-RPC server");
+        let node_ = self.node.clone();
+        self.stratum_rpc_task.clone().start(
+            listen_and_serve::<StratumRpcHandler>(stratum_rpc_settings.clone(), self.node.clone(), None, executor.clone()),
+            |res| async move {
+                match res {
+                    Ok(()) | Err(Error::RpcServerStopped) => <DarkfiNode as RequestHandler<StratumRpcHandler>>::stop_connections(&node_).await,
+                    Err(e) => error!(target: "darkfid::Darkfid::start", "Failed starting Stratum JSON-RPC server: {e}"),
+                }
+            },
+            Error::RpcServerStopped,
+            executor.clone(),
+        );
+
+        // Start the merge mining JSON-RPC task
         if let Some(mm_rpc) = mm_rpc_settings {
-            info!(target: "darkfid::Darkfid::start", "Starting HTTP JSON-RPC server");
+            info!(target: "darkfid::Darkfid::start", "Starting merge mining JSON-RPC server");
             let node_ = self.node.clone();
             self.mm_rpc_task.clone().start(
                 listen_and_serve::<MmRpcHandler>(mm_rpc.clone(), self.node.clone(), None, executor.clone()),
                 |res| async move {
                     match res {
                         Ok(()) | Err(Error::RpcServerStopped) => <DarkfiNode as RequestHandler<MmRpcHandler>>::stop_connections(&node_).await,
-                        Err(e) => error!(target: "darkfid::Darkfid::start", "Failed starting HTTP JSON-RPC server: {e}"),
+                        Err(e) => error!(target: "darkfid::Darkfid::start", "Failed starting merge mining JSON-RPC server: {e}"),
                     }
                 },
                 Error::RpcServerStopped,
@@ -329,12 +381,16 @@ impl Darkfid {
         self.dnet_task.stop().await;
 
         // Stop the JSON-RPC task
-        info!(target: "darkfid::Darkfid::stop", "Stopping JSON-RPC server...");
+        info!(target: "darkfid::Darkfid::stop", "Stopping main JSON-RPC server...");
         self.rpc_task.stop().await;
 
-        // Stop the HTTP JSON-RPC task
-        info!(target: "darkfid::Darkfid::stop", "Stopping HTTP JSON-RPC server...");
-        self.rpc_task.stop().await;
+        // Stop the Stratum JSON-RPC task
+        info!(target: "darkfid::Darkfid::stop", "Stopping Stratum JSON-RPC server...");
+        self.stratum_rpc_task.stop().await;
+
+        // Stop the merge mining JSON-RPC task
+        info!(target: "darkfid::Darkfid::stop", "Stopping merge mining JSON-RPC server...");
+        self.mm_rpc_task.stop().await;
 
         // Stop the P2P network
         info!(target: "darkfid::Darkfid::stop", "Stopping P2P network protocols handler...");

+ 7 - 2
bin/darkfid/src/main.rs

@@ -132,11 +132,15 @@ pub struct BlockchainNetwork {
     net: SettingsOpt,
 
     #[structopt(flatten)]
-    /// JSON-RPC settings
+    /// Main server JSON-RPC settings
     rpc: RpcSettingsOpt,
 
+    #[structopt(flatten)]
+    /// Stratum server JSON-RPC settings
+    stratum_rpc: RpcSettingsOpt,
+
     #[structopt(skip)]
-    /// Optional JSON-RPC settings for p2pool merge mining requests
+    /// Merge mining server JSON-RPC settings (optional)
     mm_rpc: Option<RpcSettingsOpt>,
 }
 
@@ -244,6 +248,7 @@ async fn realmain(args: Args, ex: Arc<smol::Executor<'static>>) -> Result<()> {
         .start(
             &ex,
             &blockchain_config.rpc.into(),
+            &blockchain_config.stratum_rpc.into(),
             &blockchain_config.mm_rpc.map(|mm_rpc_opts| mm_rpc_opts.into()),
             &config,
         )

+ 28 - 2
bin/darkfid/src/rpc.rs

@@ -36,9 +36,11 @@ use darkfi::{
 
 use crate::DarkfiNode;
 
-/// Default JSON-RPC `RequestHandler` type
+/// Default JSON-RPC `RequestHandler`
 pub struct DefaultRpcHandler;
-/// HTTP JSON-RPC `RequestHandler` type for p2pool
+/// JSON-RPC `RequestHandler` for Stratum
+pub struct StratumRpcHandler;
+/// HTTP JSON-RPC `RequestHandler` for p2pool/merge mining
 pub struct MmRpcHandler;
 
 #[async_trait]
@@ -84,9 +86,11 @@ impl RequestHandler<DefaultRpcHandler> for DarkfiNode {
             // =============
             // Miner methods
             // =============
+            /*
             "miner.get_current_mining_randomx_key" => self.miner_get_current_mining_randomx_key(req.id, req.params).await,
             "miner.get_header" => self.miner_get_header(req.id, req.params).await,
             "miner.submit_solution" => self.miner_submit_solution(req.id, req.params).await,
+            */
 
             // ==============
             // Invalid method
@@ -100,6 +104,28 @@ impl RequestHandler<DefaultRpcHandler> for DarkfiNode {
     }
 }
 
+#[async_trait]
+#[rustfmt::skip]
+impl RequestHandler<StratumRpcHandler> for DarkfiNode {
+	async fn handle_request(&self, req: JsonRequest) -> JsonResult {
+		debug!(target: "darkfid::stratum_rpc", "--> {}", req.stringify().unwrap());
+
+		match req.method.as_str() {
+			// ======================
+			// Stratum mining methods
+			// ======================
+			"login" => self.stratum_login(req.id, req.params).await,
+			"submit" => self.stratum_submit(req.id, req.params).await,
+			"keepalived" => self.stratum_keepalived(req.id, req.params).await,
+			_ => JsonError::new(ErrorCode::MethodNotFound, None, req.id).into(),
+		}
+	}
+
+    async fn connections_mut(&self) -> MutexGuard<'life0, HashSet<StoppableTaskPtr>> {
+        self.stratum_rpc_connections.lock().await
+    }
+}
+
 #[async_trait]
 #[rustfmt::skip]
 impl RequestHandler<MmRpcHandler> for DarkfiNode {

+ 370 - 0
bin/darkfid/src/rpc_stratum.rs

@@ -0,0 +1,370 @@
+/* This file is part of DarkFi (https://dark.fi)
+ *
+ * Copyright (C) 2020-2025 Dyne.org foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+use std::{collections::HashMap, str::FromStr};
+
+use darkfi::{
+    rpc::jsonrpc::{ErrorCode, ErrorCode::InvalidParams, JsonError, JsonResponse, JsonResult},
+    util::{encoding::base64, time::Timestamp},
+    validator::consensus::Proposal,
+};
+use darkfi_sdk::crypto::keypair::Address;
+use darkfi_serial::serialize_async;
+use tinyjson::JsonValue;
+use tracing::{error, info};
+
+use crate::{
+    proto::ProposalMessage,
+    rpc_miner::{generate_next_block, MinerRewardsRecipientConfig},
+    BlockTemplate, DarkfiNode, MiningJobs,
+};
+
+// https://github.com/xmrig/xmrig-proxy/blob/master/doc/STRATUM.md
+// https://github.com/xmrig/xmrig-proxy/blob/master/doc/STRATUM_EXT.md
+
+// TODO: We often just return InvalidParams. These should be cleaned up
+// and more verbose.
+// TODO: The jobs storing method is not the most ideal. Think of a better one.
+// `self.mining_jobs`
+
+// Random testnet address for reference:
+// fUfG4WhbHP5C2MhYW3FHctVqi2jfXHamoQeU8KiirKVtoMBEUejkwq9F
+
+impl DarkfiNode {
+    // RPCAPI:
+    // Miner sends a `login` request after establishing connection
+    // in order to authorize.
+    //
+    // The server will return a job along with an id.
+    // ```
+    // "job": {
+    //     "blob": 070780e6b9d...4d62fa6c77e76c3001",
+    //     "job_id": "q7PLUPL25UV0z5Ij14IyMk8htXbj",
+    //     "target": "b88d0600",
+    //     "algo": "rx/0"
+    // }
+    // ```
+    //
+    // --> {"jsonrpc":"2.0", "method": "login", "id": 1, "params": {"login": "receiving_address", "pass": "x", "agent": "XMRig", "algo": ["rx/0"]}}
+    // <-- {"jsonrpc":"2.0", "id": 1, "result": {"id": "1be0b7b6-b15a-47be-a17d-46b2911cf7d0", "job": { ... }, "status": "OK"}}
+    pub async fn stratum_login(&self, id: u16, params: JsonValue) -> JsonResult {
+        // TODO: Fail when not synced
+        let Some(params) = params.get::<HashMap<String, JsonValue>>() else {
+            return JsonError::new(InvalidParams, None, id).into()
+        };
+        let Some(login) = params.get("login") else {
+            return JsonError::new(InvalidParams, Some("Missing 'login'".to_string()), id).into()
+        };
+        let Some(login) = login.get::<String>() else {
+            return JsonError::new(InvalidParams, None, id).into()
+        };
+        let Some(pass) = params.get("pass") else {
+            return JsonError::new(InvalidParams, Some("Missing 'pass'".to_string()), id).into()
+        };
+        let Some(_pass) = pass.get::<String>() else {
+            return JsonError::new(InvalidParams, None, id).into()
+        };
+        let Some(agent) = params.get("agent") else {
+            return JsonError::new(InvalidParams, Some("Missing 'agent'".to_string()), id).into()
+        };
+        let Some(agent) = agent.get::<String>() else {
+            return JsonError::new(InvalidParams, None, id).into()
+        };
+        let Some(algo) = params.get("algo") else {
+            return JsonError::new(InvalidParams, Some("Missing 'algo'".to_string()), id).into()
+        };
+        let Some(algo) = algo.get::<Vec<JsonValue>>() else {
+            return JsonError::new(InvalidParams, None, id).into()
+        };
+
+        // Try to parse `login` as valid address. This will be the
+        // block reward recipient.
+        let Ok(address) = Address::from_str(login) else {
+            return JsonError::new(InvalidParams, Some("Invalid address".to_string()), id).into()
+        };
+        if address.network() != self.network {
+            return JsonError::new(InvalidParams, Some("Invalid address prefix".to_string()), id)
+                .into()
+        }
+
+        // Iterate through `algo` to see if "rx/0" is supported.
+        // rx/0 is RandomX.
+        let mut found_rx0 = false;
+        for i in algo {
+            let Some(algo) = i.get::<String>() else {
+                return JsonError::new(InvalidParams, None, id).into()
+            };
+            if algo == "rx/0" {
+                found_rx0 = true;
+                break
+            }
+        }
+        if !found_rx0 {
+            return JsonError::new(InvalidParams, Some("rx/0 not supported".to_string()), id).into()
+        }
+
+        info!("[STRATUM] Got login from {} ({})", address, agent);
+        let conn_id = {
+            let mut hasher = blake3::Hasher::new();
+            hasher.update(&address.to_string().into_bytes());
+            hasher.update(&Timestamp::current_time().inner().to_le_bytes());
+            hasher.finalize().as_bytes().clone()
+        };
+
+        // Now we should register this login, and create a blocktemplate and
+        // a job for them.
+        // TODO: We also have to spawn the notification task that will send
+        // JSONRPC notifications to this connection when a new job is available.
+
+        // We'll clear any existing jobs for this login.
+        let mut mining_jobs = self.mining_jobs.lock().await;
+        mining_jobs.insert(conn_id, MiningJobs::default());
+
+        // Find applicable chain fork
+        let mut extended_fork = match self.validator.best_current_fork().await {
+            Ok(f) => f,
+            Err(e) => {
+                error!(
+                    target: "darkfid::rpc_stratum::stratum_login",
+                    "[STRATUM] Finding best fork index failed: {e}",
+                );
+                return JsonError::new(ErrorCode::InternalError, None, id).into()
+            }
+        };
+
+        // Query the Validator for a new blocktemplate.
+        // We first need to construct `MinerRewardsRecipientConfig` from the
+        // address configuration provided to us through the RPC.
+        // TODO: Parse any spend hook from the login. We might also want to
+        // define a specific address format if it includes extra data.
+        // We could also include arbitrary information in the login password.
+        let recipient_config =
+            MinerRewardsRecipientConfig { recipient: address, spend_hook: None, user_data: None };
+
+        // Find next block target
+        let target = self.validator.consensus.module.read().await.target;
+
+        // Generate blocktemplate with all the information.
+        // This will return the mining target, the entire block, and the
+        // ephemeral secret used to sign the mined block.
+        let (target, block, secret) = match generate_next_block(
+            &mut extended_fork,
+            &recipient_config,
+            &self.powrewardv1_zk.zkbin,
+            &self.powrewardv1_zk.provingkey,
+            target,
+            self.validator.verify_fees,
+        )
+        .await
+        {
+            Ok(v) => v,
+            Err(e) => {
+                error!(
+                    target: "darkfid::rpc_stratum::stratum_login",
+                    "[STRATUM] Failed to generate next blocktemplate: {e}",
+                );
+                return JsonError::new(ErrorCode::InternalError, None, id).into()
+            }
+        };
+
+        // Reference the RandomX dataset seed
+        // TODO: We can also send `next_seed_hash` when we know it.
+        let seed_hash = extended_fork.module.darkfi_rx_keys.0.inner();
+
+        // We will store this in our mining jobs map for reference when
+        // a miner solution is submitted.
+        let blocktemplate =
+            BlockTemplate { block, randomx_key: *seed_hash, target: target.clone(), secret };
+
+        // Construct everything needed for the Stratum response.
+        let blob = blocktemplate.block.header.to_blockhashing_blob();
+        let job_id = blocktemplate.block.header.hash().inner().clone();
+        let height = blocktemplate.block.header.height as f64;
+        // The target should be compacted to 8 bytes little-endian.
+        let target = &target.to_bytes_le()[..8];
+
+        // Store the job. unwrap should be fine because we created this above.
+        let jobs = mining_jobs.get_mut(&conn_id).unwrap();
+        jobs.insert(job_id, blocktemplate);
+
+        // Construct response
+        let job: HashMap<String, JsonValue> = HashMap::from([
+            ("blob".to_string(), hex::encode(&blob).to_string().into()),
+            ("job_id".to_string(), hex::encode(&job_id).to_string().into()),
+            ("height".to_string(), height.into()),
+            ("target".to_string(), hex::encode(target).into()),
+            ("algo".to_string(), "rx/0".to_string().into()),
+            ("seed_hash".to_string(), hex::encode(&seed_hash).into()),
+        ]);
+
+        let result = HashMap::from([
+            ("id".to_string(), hex::encode(&conn_id).into()),
+            ("job".to_string(), job.into()),
+            ("status".to_string(), "OK".to_string().into()),
+        ]);
+
+        // Ship it.
+        JsonResponse::new(result.into(), id).into()
+    }
+
+    // RPCAPI:
+    // Miner submits a job solution.
+    //
+    // --> {"jsonrpc":"2.0", "method": "submit", "id": 1, "params": {"id": "...", "job_id": "...", "nonce": "d0030040", "result": "e1364b8782719d7683e2ccd3d8f724bc59dfa780a9e960e7c0e0046acdb40100"}}
+    // <-- {"jsonrpc":"2.0", "id": 1, "result": {"status": "OK"}}
+    pub async fn stratum_submit(&self, id: u16, params: JsonValue) -> JsonResult {
+        // TODO: Maybe grab an exclusive lock to avoid the xmrig spam while
+        // we're doing the pow verification. xmrig spams us whenever it gets
+        // a solution, and this will end up in cloning a bunch of blocktemplates
+        // and is going to cause memory usage to go up significantly.
+        // Ideally we should block here until we finish each submit one-by-one
+        // and find a valid one. Then when we do find a valid one, we should
+        // clear the existing job(s) so this method will just return an error
+        // and not have to do all the block shenanigans.
+        // Additionally when a block is proposed successfully, the node should
+        // send a new job notification to xmrig so we should be fine.
+        // That notification part should also clear the existing jobs.
+        let Some(params) = params.get::<HashMap<String, JsonValue>>() else {
+            return JsonError::new(InvalidParams, None, id).into()
+        };
+        let Some(conn_id) = params.get("id") else {
+            return JsonError::new(InvalidParams, Some("Missing 'id'".to_string()), id).into()
+        };
+        let Some(conn_id) = conn_id.get::<String>() else {
+            return JsonError::new(InvalidParams, None, id).into()
+        };
+        let Some(job_id) = params.get("job_id") else {
+            return JsonError::new(InvalidParams, Some("Missing 'job_id'".to_string()), id).into()
+        };
+        let Some(job_id) = job_id.get::<String>() else {
+            return JsonError::new(InvalidParams, None, id).into()
+        };
+        let Some(nonce) = params.get("nonce") else {
+            return JsonError::new(InvalidParams, Some("Missing 'nonce'".to_string()), id).into()
+        };
+        let Some(nonce) = nonce.get::<String>() else {
+            return JsonError::new(InvalidParams, None, id).into()
+        };
+        // result is the RandomX calculated hash. Useful to verify/debug.
+        let Some(result) = params.get("result") else {
+            return JsonError::new(InvalidParams, Some("Missing 'result'".to_string()), id).into()
+        };
+        let Some(_result) = result.get::<String>() else {
+            return JsonError::new(InvalidParams, None, id).into()
+        };
+
+        let Ok(conn_id) = hex::decode(&conn_id) else {
+            return JsonError::new(InvalidParams, None, id).into()
+        };
+        if conn_id.len() != 32 {
+            return JsonError::new(InvalidParams, None, id).into()
+        }
+        let Ok(job_id) = hex::decode(&job_id) else {
+            return JsonError::new(InvalidParams, None, id).into()
+        };
+        if job_id.len() != 32 {
+            return JsonError::new(InvalidParams, None, id).into()
+        }
+
+        let conn_id: [u8; 32] = conn_id.try_into().unwrap();
+        let job_id: [u8; 32] = job_id.try_into().unwrap();
+
+        // We should be aware of this conn_id and job_id.
+        let mut mining_jobs = self.mining_jobs.lock().await;
+        let Some(jobs) = mining_jobs.get_mut(&conn_id) else {
+            return JsonError::new(InvalidParams, None, id).into()
+        };
+        // Get the blocktemplate.
+        let Some(blocktemplate) = jobs.get_mut(&job_id) else {
+            return JsonError::new(InvalidParams, None, id).into()
+        };
+
+        // Parse the nonce into u32.
+        let Ok(nonce_bytes) = hex::decode(&nonce) else {
+            return JsonError::new(InvalidParams, None, id).into()
+        };
+        if nonce_bytes.len() != 4 {
+            return JsonError::new(InvalidParams, None, id).into()
+        }
+        let nonce = u32::from_le_bytes(nonce_bytes.try_into().unwrap());
+
+        // We clone the block, update the nonce,
+        // sign it, and ship it into a proposal.
+        let mut block = blocktemplate.block.clone();
+        block.header.nonce = nonce;
+        block.sign(&blocktemplate.secret);
+
+        info!(
+            target: "darkfid::rpc_stratum::stratum_submit",
+            "[STRATUM] Proposing new block to network",
+        );
+        let proposal = Proposal::new(block);
+        if let Err(e) = self.validator.append_proposal(&proposal).await {
+            error!(
+                target: "darkfid::rpc_stratum::stratum_submit",
+                "[STRATUM] Error proposing new block: {e}",
+            );
+            return JsonError::new(InvalidParams, None, id).into()
+        }
+
+        // Proposal passed. We will now clear the jobs as it's assumed
+        // a new block needs to be mined.
+        mining_jobs.insert(conn_id, MiningJobs::default());
+
+        // Broadcast to network
+        let proposals_sub = self.subscribers.get("proposals").unwrap();
+        let enc_prop = JsonValue::String(base64::encode(&serialize_async(&proposal).await));
+        proposals_sub.notify(vec![enc_prop].into()).await;
+
+        info!(
+            target: "darkfid::rpc_stratum::stratum_submit",
+            "[STRATUM] Broadcasting new block to network",
+        );
+        let message = ProposalMessage(proposal);
+        self.p2p_handler.p2p.broadcast(&message).await;
+
+        JsonResponse::new(
+            HashMap::from([("status".to_string(), "OK".to_string().into())]).into(),
+            id,
+        )
+        .into()
+    }
+
+    // RPCAPI:
+    // Miner sends `keepalived` to prevent connection timeout.
+    //
+    // --> {"jsonrpc":"2.0", "method": "keepalived", "id": 1, "params": {"id": "foo"}}
+    // <-- {"jsonrpc":"2.0", "id": 1, "result": {"status": "KEEPALIVED"}}
+    pub async fn stratum_keepalived(&self, id: u16, params: JsonValue) -> JsonResult {
+        let Some(params) = params.get::<HashMap<String, JsonValue>>() else {
+            return JsonError::new(InvalidParams, None, id).into()
+        };
+        let Some(_conn_id) = params.get("id") else {
+            return JsonError::new(InvalidParams, Some("Missing 'id'".to_string()), id).into()
+        };
+
+        // TODO: This conn_id should likely exist. We should probably check
+        // that. Otherwise we might not want to reply at all.
+
+        JsonResponse::new(
+            JsonValue::from(HashMap::from([("status".into(), "KEEPALIVED".to_string().into())])),
+            id,
+        )
+        .into()
+    }
+}

+ 13 - 3
bin/darkfid/src/tests/mod.rs

@@ -280,10 +280,14 @@ fn darkfid_programmatic_control() -> Result<()> {
                     checkpoint_height: None,
                     checkpoint: None,
                 };
-                let rpc_settings = RpcSettings {
+                let main_rpc_settings = RpcSettings {
                     listen: Url::parse("tcp://127.0.0.1:8240").unwrap(),
                     ..RpcSettings::default()
                 };
+                let stratum_rpc_settings = RpcSettings {
+                    listen: Url::parse("tcp://127..0.0.1:8241").unwrap(),
+                    ..RpcSettings::default()
+                };
 
                 // Initialize a daemon
                 let daemon = crate::Darkfid::init(
@@ -298,13 +302,19 @@ fn darkfid_programmatic_control() -> Result<()> {
                 .unwrap();
 
                 // Start it
-                daemon.start(&ex, &rpc_settings, &None, &consensus_config).await.unwrap();
+                daemon
+                    .start(&ex, &main_rpc_settings, &stratum_rpc_settings, &None, &consensus_config)
+                    .await
+                    .unwrap();
 
                 // Stop it
                 daemon.stop().await.unwrap();
 
                 // Start it again
-                daemon.start(&ex, &rpc_settings, &None, &consensus_config).await.unwrap();
+                daemon
+                    .start(&ex, &main_rpc_settings, &stratum_rpc_settings, &None, &consensus_config)
+                    .await
+                    .unwrap();
 
                 // Stop it
                 daemon.stop().await.unwrap();