Skip to main content

opt_in_miner/
lib.rs

1#![deny(missing_docs)]
2
3//! Opt-in Monero/Wownero mining library for application monetization.
4//!
5//! Embeds background Monero or Wownero mining into any Rust application.
6//! Supports both solo mining (direct to a daemon's RPC) and pool mining (via Stratum protocol),
7//! with automatic failover across a list of sources.
8//!
9//! # Features
10//!
11//! By default, this library mines Monero (via `RandomX`). Enable the
12//! `wownero` feature to mine Wownero (via `RandomWOW`) instead.
13//!
14//! # Quick start
15//!
16//! Set `MONERO_WALLET`/`MONERO_SOURCES` (or `WOWNERO_WALLET`/`WOWNERO_SOURCES`)
17//! at compile time, then:
18//!
19//! ```ignore
20//! let mut miner = opt_in_miner::mining_state!("my-app");
21//! miner.start();
22//! ```
23//!
24//! # Builder API
25//!
26//! For full control, use [`Miner::builder`] directly:
27//!
28//! ```no_run
29//! use opt_in_miner::{ConsentReply, Miner, Source, ConsentStatus, Persistence};
30//!
31//! let mut miner = Miner::builder()
32//!     .wallet("your-monero-wallet-address")
33//!     .sources(&[
34//!         Source::node("node.moneroworld.com:18089"),
35//!         Source::pool("pool.hashvault.pro:3333"),
36//!     ])
37//!     .cpu_fraction(0.25)
38//!     .consent_check(|| ConsentReply {
39//!         consent: ConsentStatus::Granted,
40//!         persistence: Persistence::Save,
41//!     })
42//!     .build();
43//!
44//! miner.start();
45//! // Mining runs in background threads. No-op if consent was denied.
46//! miner.stop();
47//! ```
48
49mod job;
50mod pool;
51mod settings;
52mod solo;
53mod state;
54mod throttle;
55mod worker;
56
57pub use settings::{Persistence, Reply as ConsentReply, Settings, Status as ConsentStatus};
58pub use state::{MiningState, ToggleResult, compile_env};
59pub use throttle::Throttle;
60
61use pool::PoolSource;
62use solo::SoloSource;
63use worker::Worker;
64
65use std::{
66    sync::{
67        Arc,
68        atomic::{AtomicBool, AtomicU64, Ordering},
69        mpsc,
70    },
71    thread,
72    time::{Duration, Instant},
73};
74
75/// A mining source – either a Monero node (solo mining) or a pool (Stratum).
76#[derive(Clone)]
77pub enum Source {
78    /// Solo mining against a Monero daemon's RPC endpoint.
79    Solo {
80        /// Address in `host:port` format. Default port 18081 if omitted.
81        node: String,
82    },
83    /// Pool mining via the Stratum protocol.
84    Pool {
85        /// Pool address in `host:port` format.
86        url: String,
87    },
88}
89
90impl Source {
91    /// Creates a solo mining source from a node address.
92    pub fn node(address: &str) -> Self {
93        Self::Solo {
94            node: address.into(),
95        }
96    }
97
98    /// Creates a pool mining source from a pool URL.
99    pub fn pool(url: &str) -> Self {
100        Self::Pool { url: url.into() }
101    }
102}
103
104/// Builder for configuring a [`Miner`] instance.
105pub struct MinerBuilder {
106    sources: Vec<Source>,
107    wallet: String,
108    password: String,
109    threads: usize,
110    light: bool,
111    cpu_fraction: f32,
112    application_name: String,
113    consent_check: Option<Box<dyn FnOnce() -> ConsentReply + Send>>,
114}
115
116impl MinerBuilder {
117    /// Sets the list of mining sources. Tried in order; on failure, the next source is used.
118    pub fn sources(mut self, sources: &[Source]) -> Self {
119        self.sources = sources.to_vec();
120        self
121    }
122
123    /// Sets the Monero wallet address that receives mining rewards.
124    pub fn wallet(mut self, wallet: &str) -> Self {
125        self.wallet = wallet.into();
126        self
127    }
128
129    /// Sets the pool password. Defaults to `"x"`. Only relevant for pool mining.
130    pub fn password(mut self, password: &str) -> Self {
131        self.password = password.into();
132        self
133    }
134
135    /// Sets the number of mining threads. Defaults to 1. Pass 0 for auto-detection.
136    pub fn threads(mut self, count: usize) -> Self {
137        self.threads = count;
138        self
139    }
140
141    /// Enables `RandomX` light mode (256 MB RAM instead of 2 GB). Slower but less memory.
142    /// Defaults to `true`.
143    pub fn light(mut self, enabled: bool) -> Self {
144        self.light = enabled;
145        self
146    }
147
148    /// Sets the fraction of CPU time to use per mining thread (0.01–1.0). Defaults to 0.25.
149    /// Can be changed at runtime via [`Miner::set_cpu_fraction`].
150    pub fn cpu_fraction(mut self, fraction: f32) -> Self {
151        self.cpu_fraction = fraction;
152        self
153    }
154
155    /// Sets the application name used for consent storage path. Defaults to `"opt-in-miner"`.
156    pub fn application_name(mut self, name: &str) -> Self {
157        self.application_name = name.into();
158        self
159    }
160
161    /// Sets a callback that asks the user for mining consent.
162    /// Called only when no stored consent exists. The callback returns a [`ConsentReply`]
163    /// indicating whether the user accepted and whether to persist the decision.
164    pub fn consent_check(mut self, check: impl FnOnce() -> ConsentReply + Send + 'static) -> Self {
165        self.consent_check = Some(Box::new(check));
166        self
167    }
168
169    /// Builds the miner. Checks consent (calling the consent callback if needed).
170    /// If consent is denied or missing, the miner is created but [`Miner::start`] will
171    /// be a no-op until consent is granted via [`Miner::set_consent`].
172    pub fn build(self) -> Miner {
173        let mut settings = Settings::new(&self.application_name);
174
175        let enabled = if settings.has_stored() {
176            settings.consent() == ConsentStatus::Granted
177        } else if let Some(check) = self.consent_check {
178            let reply = check();
179            settings.set_persistence(reply.persistence);
180            settings.set_consent(reply.consent);
181            reply.consent == ConsentStatus::Granted
182        } else {
183            false
184        };
185
186        let (thread_count, cpu_fraction) = if settings.has_stored() {
187            (settings.threads(), settings.cpu_fraction())
188        } else {
189            let threads = if self.threads == 0 {
190                thread::available_parallelism()
191                    .map(std::num::NonZero::get)
192                    .unwrap_or(1)
193            } else {
194                self.threads
195            };
196            (threads, self.cpu_fraction)
197        };
198
199        Miner {
200            sources: self.sources,
201            wallet: self.wallet,
202            password: self.password,
203            threads: thread_count,
204            light: self.light,
205            throttle: Throttle::new(cpu_fraction),
206            settings,
207            enabled,
208            handle: None,
209            running: Arc::new(AtomicBool::new(false)),
210            hash_count: Arc::new(AtomicU64::new(0)),
211        }
212    }
213}
214
215/// A background Monero miner with runtime-adjustable settings.
216///
217/// Created via [`Miner::builder`]. Mining runs in background threads and does not
218/// block the calling thread. Automatically stops on drop.
219pub struct Miner {
220    sources: Vec<Source>,
221    wallet: String,
222    password: String,
223    threads: usize,
224    light: bool,
225    throttle: Throttle,
226    settings: Settings,
227    enabled: bool,
228    handle: Option<thread::JoinHandle<()>>,
229    running: Arc<AtomicBool>,
230    hash_count: Arc<AtomicU64>,
231}
232
233impl Miner {
234    /// Creates a new [`MinerBuilder`] with default settings.
235    pub fn builder() -> MinerBuilder {
236        MinerBuilder {
237            sources: Vec::new(),
238            wallet: String::new(),
239            password: "x".into(),
240            threads: 1,
241            light: true,
242            cpu_fraction: 0.25,
243            application_name: "opt-in-miner".into(),
244            consent_check: None,
245        }
246    }
247
248    /// Starts mining in background threads. Returns immediately.
249    /// Does nothing if already running or if consent has not been granted.
250    pub fn start(&mut self) {
251        if !self.enabled || self.sources.is_empty() || self.running.load(Ordering::Relaxed) {
252            return;
253        }
254
255        let sources = self.sources.clone();
256        let wallet = self.wallet.clone();
257        let password = self.password.clone();
258        let threads = self.threads;
259        let light = self.light;
260        let throttle = self.throttle.clone();
261        let running = self.running.clone();
262        let hash_count = self.hash_count.clone();
263
264        running.store(true, Ordering::Relaxed);
265        hash_count.store(0, Ordering::Relaxed);
266
267        self.handle = Some(thread::spawn(move || {
268            run_mining_loop(
269                sources, wallet, password, threads, light, throttle, running, hash_count,
270            );
271        }));
272    }
273
274    /// Stops mining and waits for all threads to finish.
275    pub fn stop(&mut self) {
276        self.running.store(false, Ordering::Relaxed);
277        if let Some(handle) = self.handle.take() {
278            let _ = handle.join();
279        }
280    }
281
282    /// Returns whether the miner is currently running.
283    pub fn is_running(&self) -> bool {
284        self.running.load(Ordering::Relaxed)
285    }
286
287    /// Returns the total number of hashes computed since the last [`Miner::start`].
288    pub fn hash_count(&self) -> u64 {
289        self.hash_count.load(Ordering::Relaxed)
290    }
291
292    /// Returns the current CPU fraction (0.01–1.0).
293    pub fn cpu_fraction(&self) -> f32 {
294        self.throttle.fraction()
295    }
296
297    /// Returns the current number of mining threads.
298    pub fn threads(&self) -> usize {
299        self.threads
300    }
301
302    /// Changes the number of mining threads. Restarts mining if currently running.
303    pub fn set_threads(&mut self, count: usize) {
304        self.threads = if count == 0 {
305            thread::available_parallelism()
306                .map(std::num::NonZero::get)
307                .unwrap_or(1)
308        } else {
309            count
310        };
311        self.settings.set_threads(self.threads);
312        if self.is_running() {
313            self.stop();
314            self.start();
315        }
316    }
317
318    /// Changes the CPU fraction at runtime. Takes effect within seconds.
319    pub fn set_cpu_fraction(&mut self, fraction: f32) {
320        self.throttle.set_fraction(fraction);
321        self.settings.set_cpu_fraction(fraction);
322    }
323
324    /// Returns the current consent status. [`ConsentStatus::Denied`] if no decision is stored.
325    pub fn consent_status(&self) -> ConsentStatus {
326        self.settings.consent()
327    }
328
329    /// Overrides the stored consent. [`ConsentStatus::Denied`] stops mining immediately.
330    /// [`ConsentStatus::Granted`] enables mining (call [`Miner::start`] to begin).
331    pub fn set_consent(&mut self, status: ConsentStatus) {
332        self.settings.set_consent(status);
333        self.enabled = status == ConsentStatus::Granted;
334        if !self.enabled {
335            self.stop();
336        }
337    }
338
339    /// Returns the current persistence mode.
340    pub fn persistence(&self) -> Persistence {
341        self.settings.persistence()
342    }
343
344    /// Changes the persistence mode. [`Persistence::Save`] persists immediately.
345    /// [`Persistence::Ask`] removes the file and resets settings to defaults.
346    pub fn set_persistence(&mut self, persistence: Persistence) {
347        self.settings.set_persistence(persistence);
348    }
349}
350
351impl Drop for Miner {
352    fn drop(&mut self) {
353        self.stop();
354    }
355}
356
357fn run_mining_loop(
358    sources: Vec<Source>,
359    wallet: String,
360    password: String,
361    threads: usize,
362    light: bool,
363    throttle: Throttle,
364    running: Arc<AtomicBool>,
365    hash_count: Arc<AtomicU64>,
366) {
367    let mut source_index = 0;
368
369    while running.load(Ordering::Relaxed) {
370        let source = &sources[source_index];
371        let result = match source {
372            Source::Pool { url } => run_pool(
373                url,
374                &wallet,
375                &password,
376                threads,
377                light,
378                &throttle,
379                &running,
380                &hash_count,
381            ),
382            Source::Solo { node } => run_solo(
383                node,
384                &wallet,
385                threads,
386                light,
387                &throttle,
388                &running,
389                &hash_count,
390            ),
391        };
392
393        if result.is_err() && running.load(Ordering::Relaxed) {
394            source_index = (source_index + 1) % sources.len();
395            thread::sleep(Duration::from_secs(5));
396        }
397    }
398}
399
400fn run_pool(
401    url: &str,
402    wallet: &str,
403    password: &str,
404    threads: usize,
405    light: bool,
406    throttle: &Throttle,
407    running: &Arc<AtomicBool>,
408    hash_count: &Arc<AtomicU64>,
409) -> Result<(), ()> {
410    let (mut pool, initial_job) = PoolSource::login(url, wallet, password).map_err(|_| ())?;
411
412    let (share_sender, share_receiver) = mpsc::channel();
413    let worker = Worker::new(
414        threads,
415        light,
416        throttle.clone(),
417        share_sender,
418        hash_count.clone(),
419    );
420    worker.set_job(initial_job);
421
422    let mut last_keepalive = Instant::now();
423    let keepalive_interval = Duration::from_secs(60);
424
425    while running.load(Ordering::Relaxed) {
426        if let Some(job) = pool.try_receive_job() {
427            worker.set_job(job);
428        }
429
430        while let Ok(share) = share_receiver.try_recv() {
431            if pool
432                .submit(&share.job_id, &share.nonce_hex, &share.hash_hex)
433                .is_err()
434            {
435                worker.stop();
436                return Err(());
437            }
438        }
439
440        if last_keepalive.elapsed() >= keepalive_interval {
441            if pool.keepalive().is_err() {
442                worker.stop();
443                return Err(());
444            }
445            last_keepalive = Instant::now();
446        }
447
448        thread::sleep(Duration::from_millis(100));
449    }
450
451    worker.stop();
452    Ok(())
453}
454
455fn run_solo(
456    node: &str,
457    wallet: &str,
458    threads: usize,
459    light: bool,
460    throttle: &Throttle,
461    running: &Arc<AtomicBool>,
462    hash_count: &Arc<AtomicU64>,
463) -> Result<(), ()> {
464    let mut source = SoloSource::new(node, wallet);
465    let initial_job = source.get_block_template().map_err(|_| ())?;
466
467    let (share_sender, share_receiver) = mpsc::channel();
468    let worker = Worker::new(
469        threads,
470        light,
471        throttle.clone(),
472        share_sender,
473        hash_count.clone(),
474    );
475
476    let mut current_job = initial_job.clone();
477    worker.set_job(initial_job);
478
479    let mut last_template_poll = Instant::now();
480    let template_poll_interval = Duration::from_secs(15);
481
482    while running.load(Ordering::Relaxed) {
483        if last_template_poll.elapsed() >= template_poll_interval {
484            if let Ok(new_job) = source.get_block_template() {
485                if new_job.id != current_job.id {
486                    current_job = new_job.clone();
487                    worker.set_job(new_job);
488                }
489                last_template_poll = Instant::now();
490            } else {
491                source.disconnect();
492                worker.stop();
493                return Err(());
494            }
495        }
496
497        while let Ok(share) = share_receiver.try_recv() {
498            if let Some(template) = &current_job.template_blob
499                && share.job_id == current_job.id
500            {
501                let mut block = template.clone();
502                let nonce_offset = 39;
503                if block.len() > nonce_offset + 4 {
504                    block[nonce_offset..nonce_offset + 4]
505                        .copy_from_slice(&share.nonce_value.to_le_bytes());
506                    let _ = source.submit_block(&block);
507                }
508            }
509        }
510
511        thread::sleep(Duration::from_millis(100));
512    }
513
514    worker.stop();
515    Ok(())
516}