Skip to main content

zeldhash_miner/
lib.rs

1use std::{
2    sync::{
3        atomic::{AtomicBool, Ordering},
4        Arc, Condvar, Mutex,
5    },
6    time::Instant,
7};
8
9#[cfg(not(feature = "rayon"))]
10use std::thread;
11
12#[cfg(feature = "rayon")]
13use rayon::prelude::*;
14use thiserror::Error;
15use zeldhash_miner_core::{
16    build_mining_template, build_psbt_from_plan, encode_cbor_uint, encode_nonce,
17    split_nonce_segments, split_nonce_segments_cbor, txid_to_hex, AddressError, FeeError,
18    MinerError, Network, NonceSegment, OutputRequest, TxInput,
19};
20use zeldhash_miner_core::{double_sha256, hash_meets_target};
21
22#[cfg(feature = "gpu")]
23use zeldhash_miner_gpu::{
24    dispatch_mining_batch, GpuContext, MineResult as GpuMineResult, MiningBatch,
25};
26
27// Accept 0 to mirror JS/WASM bindings semantics: 0 means "accept first hash".
28const MAX_TARGET_ZEROS: u8 = 32;
29#[cfg(feature = "gpu")]
30const GPU_MAX_BATCH_SIZE: u32 = 100_000;
31
32#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
33#[derive(Debug, Clone, Copy)]
34pub enum NetworkOption {
35    Mainnet,
36    Testnet,
37    Signet,
38    Regtest,
39}
40
41impl NetworkOption {
42    fn to_core(self) -> Network {
43        match self {
44            NetworkOption::Mainnet => Network::Mainnet,
45            NetworkOption::Testnet | NetworkOption::Signet => Network::Testnet,
46            NetworkOption::Regtest => Network::Regtest,
47        }
48    }
49}
50
51#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
52#[derive(Debug, Clone)]
53pub struct ZeldMinerOptions {
54    pub network: NetworkOption,
55    pub batch_size: u32,
56    pub use_gpu: bool,
57    pub worker_threads: usize,
58    pub sats_per_vbyte: u64,
59}
60
61#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
62#[derive(Debug, Clone)]
63pub struct TxInputDesc {
64    pub txid: String,
65    pub vout: u32,
66    pub script_pubkey: String,
67    pub amount: u64,
68    pub sequence: Option<u32>,
69}
70
71#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
72#[derive(Debug, Clone)]
73pub struct TxOutputDesc {
74    pub address: String,
75    pub amount: Option<u64>,
76    pub change: bool,
77}
78
79#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
80#[derive(Debug, Clone)]
81pub struct MineParams {
82    pub inputs: Vec<TxInputDesc>,
83    pub outputs: Vec<TxOutputDesc>,
84    pub target_zeros: u8,
85    pub start_nonce: Option<u64>,
86    pub batch_size: Option<u32>,
87    pub distribution: Option<Vec<u64>>,
88}
89
90#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
91#[derive(Debug, Clone)]
92pub struct MineResult {
93    pub psbt: String,
94    pub txid: String,
95    pub nonce: u64,
96    pub attempts: u128,
97    pub duration_ms: u128,
98    pub hash_rate: f64,
99}
100
101#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
102#[derive(Debug, Clone)]
103pub struct ProgressStats {
104    pub hashes_processed: u128,
105    pub hash_rate: f64,
106    pub elapsed_ms: u128,
107    pub last_nonce: Option<u64>,
108}
109
110#[cfg_attr(
111    feature = "serde",
112    derive(serde::Serialize, serde::Deserialize),
113    serde(rename_all = "snake_case")
114)]
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub enum ZeldMinerErrorCode {
117    InvalidAddress,
118    UnsupportedAddressType,
119    InsufficientFunds,
120    NoChangeOutput,
121    MultipleChangeOutputs,
122    InvalidInput,
123    WorkerError,
124    MiningAborted,
125    NoMatchingNonce,
126    DustOutput,
127}
128
129#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
130#[derive(Debug, Error)]
131#[error("{code:?}: {message}")]
132pub struct ZeldMinerError {
133    pub code: ZeldMinerErrorCode,
134    pub message: String,
135}
136
137type Result<T> = std::result::Result<T, ZeldMinerError>;
138
139impl ZeldMinerError {
140    fn new(code: ZeldMinerErrorCode, message: impl Into<String>) -> Self {
141        ZeldMinerError {
142            code,
143            message: message.into(),
144        }
145    }
146}
147
148/// Coordinates one active mining run per instance; create separate instances if you
149/// need concurrent searches to avoid cross-talk on pause/stop signals.
150pub struct ZeldMiner {
151    opts: ZeldMinerOptions,
152    control: MiningControl,
153    #[cfg(feature = "gpu")]
154    gpu_ctx: Option<GpuContext>,
155}
156
157#[derive(Default)]
158struct MiningControl {
159    stop: AtomicBool,
160    pause: AtomicBool,
161    mutex: Mutex<()>,
162    cv: Condvar,
163}
164
165impl MiningControl {
166    fn new() -> Self {
167        Self {
168            stop: AtomicBool::new(false),
169            pause: AtomicBool::new(false),
170            mutex: Mutex::new(()),
171            cv: Condvar::new(),
172        }
173    }
174
175    fn reset(&self) {
176        self.stop.store(false, Ordering::Release);
177        self.pause.store(false, Ordering::Release);
178        self.cv.notify_all();
179    }
180
181    fn request_stop(&self) {
182        self.stop.store(true, Ordering::Release);
183        self.cv.notify_all();
184    }
185
186    fn request_pause(&self) {
187        self.pause.store(true, Ordering::Release);
188    }
189
190    fn resume(&self) {
191        self.pause.store(false, Ordering::Release);
192        self.cv.notify_all();
193    }
194
195    fn wait_if_paused_or_stopped(&self) -> Result<()> {
196        if self.stop.load(Ordering::Acquire) {
197            return Err(mining_aborted_error("mining stopped"));
198        }
199        if !self.pause.load(Ordering::Acquire) {
200            return Ok(());
201        }
202
203        let mut guard = self
204            .mutex
205            .lock()
206            .expect("pause mutex should not be poisoned");
207        while self.pause.load(Ordering::Acquire) && !self.stop.load(Ordering::Acquire) {
208            guard = self.cv.wait(guard).expect("pause condvar wait failed");
209        }
210
211        if self.stop.load(Ordering::Acquire) {
212            Err(mining_aborted_error("mining stopped"))
213        } else {
214            Ok(())
215        }
216    }
217}
218
219impl ZeldMiner {
220    pub fn new(opts: ZeldMinerOptions) -> Result<Self> {
221        if opts.batch_size == 0 {
222            return Err(ZeldMinerError::new(
223                ZeldMinerErrorCode::InvalidInput,
224                "batch_size must be greater than zero",
225            ));
226        }
227        if opts.worker_threads == 0 {
228            return Err(ZeldMinerError::new(
229                ZeldMinerErrorCode::InvalidInput,
230                "worker_threads must be greater than zero",
231            ));
232        }
233        if opts.sats_per_vbyte == 0 {
234            return Err(ZeldMinerError::new(
235                ZeldMinerErrorCode::InvalidInput,
236                "sats_per_vbyte must be greater than zero",
237            ));
238        }
239        #[cfg(feature = "gpu")]
240        let gpu_ctx = if opts.use_gpu {
241            match pollster::block_on(GpuContext::init()) {
242                Ok(ctx) => Some(ctx),
243                Err(err) => {
244                    // Fall back to CPU if GPU init fails; keep error context for visibility.
245                    eprintln!("GPU initialization failed, falling back to CPU: {err}");
246                    None
247                }
248            }
249        } else {
250            None
251        };
252
253        Ok(Self {
254            opts,
255            control: MiningControl::new(),
256            #[cfg(feature = "gpu")]
257            gpu_ctx,
258        })
259    }
260
261    pub fn mine_transaction<F, G>(
262        &self,
263        params: MineParams,
264        mut on_progress: Option<F>,
265        mut on_found: Option<G>,
266    ) -> Result<MineResult>
267    where
268        F: FnMut(ProgressStats),
269        G: FnMut(&MineResult),
270    {
271        if params.target_zeros > MAX_TARGET_ZEROS {
272            return Err(ZeldMinerError::new(
273                ZeldMinerErrorCode::InvalidInput,
274                "target_zeros must be between 0 and 32",
275            ));
276        }
277
278        let batch_size = params.batch_size.unwrap_or(self.opts.batch_size);
279        if batch_size == 0 {
280            return Err(ZeldMinerError::new(
281                ZeldMinerErrorCode::InvalidInput,
282                "batch_size must be greater than zero",
283            ));
284        }
285
286        let start_nonce = params.start_nonce.unwrap_or(0);
287        let network = self.opts.network.to_core();
288        let parsed_inputs = parse_inputs(&params.inputs)?;
289        let output_requests: Vec<OutputRequest> = params
290            .outputs
291            .iter()
292            .cloned()
293            .map(OutputRequest::from)
294            .collect();
295        let distribution = params.distribution.as_deref();
296
297        let segments = if distribution.is_some() {
298            split_nonce_segments_cbor(start_nonce, batch_size)
299        } else {
300            split_nonce_segments(start_nonce, batch_size)
301        }
302        .map_err(|e| ZeldMinerError::new(ZeldMinerErrorCode::InvalidInput, e))?;
303        let started_at = Instant::now();
304        let mut attempts: u128 = 0;
305
306        self.control.reset();
307
308        let workers = self.opts.worker_threads.max(1);
309
310        for segment in segments {
311            self.control.wait_if_paused_or_stopped()?;
312
313            let placeholder = vec![0u8; segment.nonce_len as usize];
314            let plan = zeldhash_miner_core::plan_transaction(
315                parsed_inputs.clone(),
316                output_requests.clone(),
317                network,
318                self.opts.sats_per_vbyte,
319                &placeholder,
320                distribution,
321            )
322            .map_err(map_miner_error)?;
323
324            let template = build_mining_template(&plan, segment.nonce_len as usize)
325                .map_err(map_miner_error)?;
326
327            let mut attempts_this_segment: u128 = 0;
328            let mut mined: Option<(u64, [u8; 32])> = None;
329
330            if self.opts.use_gpu {
331                #[cfg(feature = "gpu")]
332                {
333                    if let Some(ctx) = self.gpu_ctx.as_ref() {
334                        if let Ok(res) = mine_segment_gpu_controlled(
335                            ctx,
336                            &template.prefix,
337                            &template.suffix,
338                            &segment,
339                            params.target_zeros,
340                            distribution.is_some(),
341                            &self.control,
342                        ) {
343                            attempts_this_segment = res.attempts;
344                            mined = res.hit;
345                            if mined.is_none() {
346                                // GPU completed successfully with no hit; do not rerun on CPU.
347                                attempts += attempts_this_segment;
348                                if let Some(cb) = on_progress.as_mut() {
349                                    let elapsed_ms = started_at.elapsed().as_millis();
350                                    cb(ProgressStats {
351                                        hashes_processed: attempts,
352                                        hash_rate: hash_rate(attempts, elapsed_ms),
353                                        elapsed_ms,
354                                        last_nonce: Some(segment.start + segment.size as u64 - 1),
355                                    });
356                                }
357                                // Honor a stop/pause request before moving to the next segment.
358                                self.control.wait_if_paused_or_stopped()?;
359                                continue;
360                            }
361                        } else {
362                            // GPU path failed; fall back to CPU for this segment.
363                        }
364                    }
365                }
366                // If GPU work concluded and a stop was requested, abort before continuing.
367                self.control.wait_if_paused_or_stopped()?;
368            }
369
370            if mined.is_none() {
371                let controlled_result = if workers > 1 {
372                    mine_segment_cpu_parallel(
373                        &template.prefix,
374                        &template.suffix,
375                        &segment,
376                        params.target_zeros,
377                        distribution.is_some(),
378                        &self.control,
379                        workers,
380                    )?
381                } else {
382                    mine_segment_cpu_controlled(
383                        &template.prefix,
384                        &template.suffix,
385                        &segment,
386                        params.target_zeros,
387                        distribution.is_some(),
388                        &self.control,
389                        None,
390                    )?
391                };
392
393                attempts_this_segment = controlled_result.attempts;
394                mined = controlled_result.hit;
395
396                if mined.is_none() {
397                    self.control.wait_if_paused_or_stopped()?;
398                    attempts += attempts_this_segment;
399                    if let Some(cb) = on_progress.as_mut() {
400                        let elapsed_ms = started_at.elapsed().as_millis();
401                        cb(ProgressStats {
402                            hashes_processed: attempts,
403                            hash_rate: hash_rate(attempts, elapsed_ms),
404                            elapsed_ms,
405                            last_nonce: Some(segment.start + segment.size as u64 - 1),
406                        });
407                    }
408                    continue;
409                }
410            }
411
412            if let Some((nonce, _txid_bytes)) = mined {
413                attempts += attempts_this_segment;
414                let nonce_bytes = if distribution.is_some() {
415                    encode_cbor_uint(nonce)
416                } else {
417                    encode_nonce(nonce)
418                };
419                let plan_with_nonce = zeldhash_miner_core::plan_transaction(
420                    parsed_inputs,
421                    output_requests,
422                    network,
423                    self.opts.sats_per_vbyte,
424                    &nonce_bytes,
425                    distribution,
426                )
427                .map_err(map_miner_error)?;
428                let (psbt, txid_bytes) =
429                    build_psbt_from_plan(&plan_with_nonce).map_err(map_miner_error)?;
430
431                let duration_ms = started_at.elapsed().as_millis();
432                let hash_rate = hash_rate(attempts, duration_ms);
433
434                let result = MineResult {
435                    psbt,
436                    txid: txid_to_hex(&txid_bytes),
437                    nonce,
438                    attempts,
439                    duration_ms,
440                    hash_rate,
441                };
442
443                if let Some(cb) = on_progress.as_mut() {
444                    cb(ProgressStats {
445                        hashes_processed: attempts,
446                        hash_rate,
447                        elapsed_ms: duration_ms,
448                        last_nonce: Some(nonce),
449                    });
450                }
451
452                if let Some(cb) = on_found.as_mut() {
453                    cb(&result);
454                }
455
456                return Ok(result);
457            }
458        }
459
460        Err(ZeldMinerError::new(
461            ZeldMinerErrorCode::NoMatchingNonce,
462            "no matching nonce found in provided range",
463        ))
464    }
465
466    pub fn stop(&self) {
467        self.control.request_stop();
468    }
469
470    pub fn pause(&self) {
471        self.control.request_pause();
472    }
473
474    pub fn resume(&self) {
475        self.control.resume();
476    }
477}
478
479fn hash_rate(attempts: u128, duration_ms: u128) -> f64 {
480    if duration_ms == 0 {
481        return 0.0;
482    }
483    attempts as f64 / (duration_ms as f64 / 1000.0)
484}
485
486fn decode_txid_hex(txid: &str) -> Result<[u8; 32]> {
487    let mut bytes = hex::decode(txid)
488        .map_err(|_| ZeldMinerError::new(ZeldMinerErrorCode::InvalidInput, "invalid txid hex"))?;
489    if bytes.len() != 32 {
490        return Err(ZeldMinerError::new(
491            ZeldMinerErrorCode::InvalidInput,
492            "txid must be 32 bytes",
493        ));
494    }
495    bytes.reverse();
496    let mut out = [0u8; 32];
497    out.copy_from_slice(&bytes);
498    Ok(out)
499}
500
501fn decode_hex_bytes(data: &str) -> Result<Vec<u8>> {
502    hex::decode(data).map_err(|_| {
503        ZeldMinerError::new(
504            ZeldMinerErrorCode::InvalidInput,
505            "script_pubkey must be valid hex",
506        )
507    })
508}
509
510fn parse_inputs(inputs: &[TxInputDesc]) -> Result<Vec<TxInput>> {
511    inputs
512        .iter()
513        .map(|input| {
514            let txid = decode_txid_hex(&input.txid)?;
515            let script_pubkey = decode_hex_bytes(&input.script_pubkey)?;
516            Ok(TxInput {
517                txid,
518                vout: input.vout,
519                script_pubkey,
520                amount: input.amount,
521                sequence: input
522                    .sequence
523                    .unwrap_or(zeldhash_miner_core::tx::DEFAULT_SEQUENCE),
524            })
525        })
526        .collect()
527}
528
529fn map_miner_error(err: MinerError) -> ZeldMinerError {
530    match err {
531        MinerError::InvalidInput(msg) => ZeldMinerError::new(ZeldMinerErrorCode::InvalidInput, msg),
532        MinerError::MultipleChangeOutputs => ZeldMinerError::new(
533            ZeldMinerErrorCode::MultipleChangeOutputs,
534            "multiple change outputs are not allowed",
535        ),
536        MinerError::Core(core) => match core {
537            zeldhash_miner_core::ZeldError::Address(addr_err) => match addr_err {
538                AddressError::UnsupportedAddressType => ZeldMinerError::new(
539                    ZeldMinerErrorCode::UnsupportedAddressType,
540                    addr_err.to_string(),
541                ),
542                _ => ZeldMinerError::new(ZeldMinerErrorCode::InvalidAddress, addr_err.to_string()),
543            },
544            zeldhash_miner_core::ZeldError::Fee(fee_err) => match fee_err {
545                FeeError::InsufficientFunds => {
546                    ZeldMinerError::new(ZeldMinerErrorCode::InsufficientFunds, fee_err.to_string())
547                }
548            },
549            zeldhash_miner_core::ZeldError::Tx(_) | zeldhash_miner_core::ZeldError::Psbt(_) => {
550                ZeldMinerError::new(ZeldMinerErrorCode::WorkerError, core.to_string())
551            }
552        },
553    }
554}
555
556fn mining_aborted_error(message: &str) -> ZeldMinerError {
557    ZeldMinerError::new(ZeldMinerErrorCode::MiningAborted, message)
558}
559
560struct ControlledMineResult {
561    attempts: u128,
562    hit: Option<(u64, [u8; 32])>,
563}
564
565fn mine_segment_cpu_controlled(
566    prefix: &[u8],
567    suffix: &[u8],
568    segment: &NonceSegment,
569    target_zeros: u8,
570    use_cbor_nonce: bool,
571    control: &MiningControl,
572    found_flag: Option<&AtomicBool>,
573) -> Result<ControlledMineResult> {
574    let nonce_len = segment.nonce_len as usize;
575    let mut buffer = Vec::with_capacity(prefix.len() + suffix.len() + nonce_len);
576    let mut nonce_buf = [0u8; 9];
577
578    for offset in 0..segment.size {
579        control.wait_if_paused_or_stopped()?;
580
581        if let Some(flag) = found_flag {
582            if flag.load(Ordering::Acquire) {
583                return Ok(ControlledMineResult {
584                    attempts: offset as u128,
585                    hit: None,
586                });
587            }
588        }
589
590        let nonce = match segment.start.checked_add(offset as u64) {
591            Some(n) => n,
592            None => {
593                return Err(ZeldMinerError::new(
594                    ZeldMinerErrorCode::InvalidInput,
595                    "nonce range overflow",
596                ))
597            }
598        };
599
600        let written =
601            encode_nonce_for_segment(nonce, segment.nonce_len, use_cbor_nonce, &mut nonce_buf)?;
602
603        buffer.clear();
604        buffer.extend_from_slice(prefix);
605        buffer.extend_from_slice(&nonce_buf[..written]);
606        buffer.extend_from_slice(suffix);
607
608        let hash = double_sha256(&buffer);
609        if hash_meets_target(&hash, target_zeros) {
610            return Ok(ControlledMineResult {
611                attempts: offset as u128 + 1,
612                hit: Some((nonce, hash)),
613            });
614        }
615    }
616
617    Ok(ControlledMineResult {
618        attempts: segment.size as u128,
619        hit: None,
620    })
621}
622
623fn split_segment_for_workers(segment: &NonceSegment, workers: usize) -> Result<Vec<NonceSegment>> {
624    let workers = workers.max(1).min(segment.size as usize);
625    let base = segment.size / workers as u32;
626    let remainder = segment.size % workers as u32;
627
628    let mut subs = Vec::with_capacity(workers);
629    let mut start = segment.start;
630
631    for idx in 0..workers {
632        let extra = if (idx as u32) < remainder { 1 } else { 0 };
633        let size = base + extra;
634        if size == 0 {
635            continue;
636        }
637        subs.push(NonceSegment {
638            start,
639            size,
640            nonce_len: segment.nonce_len,
641        });
642        start = start.checked_add(size as u64).ok_or_else(|| {
643            ZeldMinerError::new(ZeldMinerErrorCode::InvalidInput, "nonce range overflow")
644        })?;
645    }
646
647    Ok(subs)
648}
649
650fn mine_segment_cpu_parallel(
651    prefix: &[u8],
652    suffix: &[u8],
653    segment: &NonceSegment,
654    target_zeros: u8,
655    use_cbor_nonce: bool,
656    control: &MiningControl,
657    workers: usize,
658) -> Result<ControlledMineResult> {
659    if workers <= 1 || segment.size <= 1 {
660        return mine_segment_cpu_controlled(
661            prefix,
662            suffix,
663            segment,
664            target_zeros,
665            use_cbor_nonce,
666            control,
667            None,
668        );
669    }
670
671    let subs = split_segment_for_workers(segment, workers)?;
672    if subs.len() == 1 {
673        return mine_segment_cpu_controlled(
674            prefix,
675            suffix,
676            segment,
677            target_zeros,
678            use_cbor_nonce,
679            control,
680            None,
681        );
682    }
683
684    #[cfg(feature = "rayon")]
685    {
686        let found = Arc::new(AtomicBool::new(false));
687        let attempts_acc = Arc::new(Mutex::new(0u128));
688        let result = Arc::new(Mutex::new(None));
689
690        subs.into_par_iter().try_for_each(|sub| {
691            let res = mine_segment_cpu_controlled(
692                prefix,
693                suffix,
694                &sub,
695                target_zeros,
696                use_cbor_nonce,
697                control,
698                Some(found.as_ref()),
699            );
700
701            match res {
702                Ok(r) => {
703                    if r.hit.is_some() && !found.swap(true, Ordering::AcqRel) {
704                        *result.lock().expect("result mutex poisoned") = r.hit;
705                    }
706                    *attempts_acc.lock().expect("attempts mutex poisoned") += r.attempts;
707                    Ok(())
708                }
709                Err(err) => Err(err),
710            }
711        })?;
712
713        let attempts = *attempts_acc.lock().expect("attempts mutex poisoned");
714        let hit = result.lock().expect("result mutex poisoned").take();
715
716        Ok(ControlledMineResult { attempts, hit })
717    }
718
719    #[cfg(not(feature = "rayon"))]
720    {
721        let found = Arc::new(AtomicBool::new(false));
722        let attempts_acc = Arc::new(Mutex::new(0u128));
723        let result = Arc::new(Mutex::new(None));
724        let first_err = Arc::new(Mutex::new(None));
725
726        thread::scope(|scope| {
727            for sub in subs {
728                let found = Arc::clone(&found);
729                let attempts_acc = Arc::clone(&attempts_acc);
730                let result = Arc::clone(&result);
731                let first_err = Arc::clone(&first_err);
732                scope.spawn(move || {
733                    let res = mine_segment_cpu_controlled(
734                        prefix,
735                        suffix,
736                        &sub,
737                        target_zeros,
738                        use_cbor_nonce,
739                        control,
740                        Some(found.as_ref()),
741                    );
742
743                    match res {
744                        Ok(r) => {
745                            if r.hit.is_some() && !found.swap(true, Ordering::AcqRel) {
746                                *result.lock().expect("result mutex poisoned") = r.hit;
747                            }
748                            *attempts_acc.lock().expect("attempts mutex poisoned") += r.attempts;
749                        }
750                        Err(err) => {
751                            *first_err.lock().expect("error mutex poisoned") = Some(err);
752                        }
753                    }
754                });
755            }
756        });
757
758        if let Some(err) = first_err.lock().expect("error mutex poisoned").take() {
759            return Err(err);
760        }
761
762        let attempts = *attempts_acc.lock().expect("attempts mutex poisoned");
763        let hit = result.lock().expect("result mutex poisoned").take();
764
765        Ok(ControlledMineResult { attempts, hit })
766    }
767}
768
769#[cfg(feature = "gpu")]
770fn mine_segment_gpu_controlled(
771    ctx: &GpuContext,
772    prefix: &[u8],
773    suffix: &[u8],
774    segment: &NonceSegment,
775    target_zeros: u8,
776    use_cbor_nonce: bool,
777    control: &MiningControl,
778) -> Result<ControlledMineResult> {
779    let mut attempts: u128 = 0;
780    let mut remaining = segment.size;
781    let mut current_start = segment.start;
782
783    while remaining > 0 {
784        control.wait_if_paused_or_stopped()?;
785
786        let chunk = remaining.min(GPU_MAX_BATCH_SIZE);
787        let batch = MiningBatch {
788            tx_prefix: prefix,
789            tx_suffix: suffix,
790            start_nonce: current_start,
791            batch_size: chunk,
792            target_zeros,
793            use_cbor_nonce,
794        };
795
796        let results = pollster::block_on(dispatch_mining_batch(ctx, &batch))
797            .map_err(|err| ZeldMinerError::new(ZeldMinerErrorCode::WorkerError, err.to_string()))?;
798
799        if let Some(best) = select_best_gpu_result(&results) {
800            let attempts_to_hit = best
801                .nonce
802                .checked_sub(current_start)
803                .and_then(|offset| offset.checked_add(1))
804                .ok_or_else(|| {
805                    ZeldMinerError::new(ZeldMinerErrorCode::InvalidInput, "nonce range overflow")
806                })?;
807
808            attempts += attempts_to_hit as u128;
809            return Ok(ControlledMineResult {
810                attempts,
811                hit: Some((best.nonce, best.txid)),
812            });
813        }
814
815        attempts += chunk as u128;
816        current_start = current_start.checked_add(chunk as u64).ok_or_else(|| {
817            ZeldMinerError::new(ZeldMinerErrorCode::InvalidInput, "nonce range overflow")
818        })?;
819        remaining -= chunk;
820    }
821
822    Ok(ControlledMineResult {
823        attempts,
824        hit: None,
825    })
826}
827
828#[cfg(feature = "gpu")]
829fn select_best_gpu_result(results: &[GpuMineResult]) -> Option<GpuMineResult> {
830    results.iter().min_by_key(|r| r.nonce).cloned()
831}
832
833fn encode_nonce_for_segment(
834    nonce: u64,
835    nonce_len: u8,
836    use_cbor_nonce: bool,
837    out: &mut [u8; 9],
838) -> Result<usize> {
839    if use_cbor_nonce {
840        let encoded = encode_cbor_uint(nonce);
841        if encoded.len() != nonce_len as usize {
842            return Err(ZeldMinerError::new(
843                ZeldMinerErrorCode::InvalidInput,
844                "CBOR nonce length mismatch for segment",
845            ));
846        }
847        out[..encoded.len()].copy_from_slice(&encoded);
848        return Ok(encoded.len());
849    }
850
851    encode_nonce_fixed_into(nonce, nonce_len, out)
852}
853
854fn encode_nonce_fixed_into(nonce: u64, nonce_len: u8, out: &mut [u8; 9]) -> Result<usize> {
855    if nonce_len == 0 || nonce_len > 9 {
856        return Err(ZeldMinerError::new(
857            ZeldMinerErrorCode::InvalidInput,
858            "nonce_len must be between 1 and 9 bytes",
859        ));
860    }
861
862    let mut tmp = nonce;
863    let mut minimal_len = 0usize;
864    let mut buf = [0u8; 9];
865
866    if tmp == 0 {
867        minimal_len = 1;
868        buf[8] = 0;
869    } else {
870        while tmp != 0 {
871            minimal_len += 1;
872            buf[9 - minimal_len] = (tmp & 0xff) as u8;
873            tmp >>= 8;
874        }
875    }
876
877    let target_len = nonce_len as usize;
878    if minimal_len != target_len {
879        return Err(ZeldMinerError::new(
880            ZeldMinerErrorCode::InvalidInput,
881            "nonce length does not match minimal encoding",
882        ));
883    }
884
885    let src_start = 9 - minimal_len;
886    out[..target_len].copy_from_slice(&buf[src_start..]);
887    Ok(target_len)
888}
889
890impl From<TxOutputDesc> for OutputRequest {
891    fn from(value: TxOutputDesc) -> Self {
892        OutputRequest {
893            address: value.address,
894            amount: value.amount,
895            change: value.change,
896        }
897    }
898}
899
900#[cfg(test)]
901mod tests {
902    use super::*;
903    use bitcoin::bech32::{segwit, Hrp};
904    use bitcoin::psbt::Psbt;
905    use std::str::FromStr;
906    use zeldhash_miner_core::create_zeld_distribution_op_return;
907
908    fn sample_addresses() -> (String, String) {
909        let hrp = Hrp::parse("bc").expect("mainnet hrp");
910        let user_program = [0x33u8; 20];
911        let change_program = [0x22u8; 20];
912        let user = segwit::encode(hrp, segwit::VERSION_0, &user_program).unwrap();
913        let change = segwit::encode(hrp, segwit::VERSION_0, &change_program).unwrap();
914        (user, change)
915    }
916
917    fn sample_input_desc(amount: u64) -> TxInputDesc {
918        let mut spk = vec![0x00, 0x14];
919        spk.extend_from_slice(&[0x22u8; 20]);
920
921        TxInputDesc {
922            txid: "11".repeat(32),
923            vout: 0,
924            script_pubkey: hex::encode(spk),
925            amount,
926            sequence: Some(zeldhash_miner_core::tx::DEFAULT_SEQUENCE),
927        }
928    }
929
930    fn sample_outputs(user_addr: &str, change_addr: &str) -> Vec<TxOutputDesc> {
931        vec![
932            TxOutputDesc {
933                address: user_addr.to_string(),
934                amount: Some(60_000),
935                change: false,
936            },
937            TxOutputDesc {
938                address: change_addr.to_string(),
939                amount: None,
940                change: true,
941            },
942        ]
943    }
944
945    fn miner_opts(use_gpu: bool) -> ZeldMinerOptions {
946        ZeldMinerOptions {
947            network: NetworkOption::Mainnet,
948            batch_size: 4,
949            use_gpu,
950            worker_threads: 1,
951            sats_per_vbyte: 2,
952        }
953    }
954
955    fn mine_once(use_gpu: bool) -> MineResult {
956        let miner = ZeldMiner::new(miner_opts(use_gpu)).expect("miner builds");
957        let (user_addr, change_addr) = sample_addresses();
958        let params = MineParams {
959            inputs: vec![sample_input_desc(120_000)],
960            outputs: sample_outputs(&user_addr, &change_addr),
961            target_zeros: 0,
962            start_nonce: Some(0),
963            batch_size: Some(4),
964            distribution: None,
965        };
966
967        let mut progress_called = false;
968        let mut found_called = false;
969
970        let result = miner
971            .mine_transaction(
972                params,
973                Some(|stats: ProgressStats| {
974                    progress_called = true;
975                    assert!(stats.hashes_processed >= 1);
976                }),
977                Some(|_: &MineResult| found_called = true),
978            )
979            .expect("mining succeeds");
980
981        assert!(progress_called, "progress callback should fire");
982        assert!(found_called, "found callback should fire");
983        result
984    }
985
986    #[test]
987    fn cpu_path_mines_and_builds_psbt() {
988        let result = mine_once(false);
989        assert_eq!(result.nonce, 0);
990        assert!(result.attempts >= 1);
991
992        let psbt = Psbt::from_str(&result.psbt).expect("psbt parses");
993        let txid = psbt.unsigned_tx.compute_txid().to_string();
994        assert_eq!(txid, result.txid);
995    }
996
997    #[test]
998    fn mines_with_custom_distribution() {
999        let miner = ZeldMiner::new(miner_opts(false)).expect("miner builds");
1000        let (user_addr, change_addr) = sample_addresses();
1001        let hrp = Hrp::parse("bc").expect("mainnet hrp");
1002        let alt_addr = segwit::encode(hrp, segwit::VERSION_0, &[0x44u8; 20]).unwrap();
1003        // Distribution now must match total outputs (including change).
1004        let distribution = vec![600u64, 400, 0];
1005
1006        let params = MineParams {
1007            inputs: vec![sample_input_desc(150_000)],
1008            outputs: vec![
1009                TxOutputDesc {
1010                    address: user_addr,
1011                    amount: Some(60_000),
1012                    change: false,
1013                },
1014                TxOutputDesc {
1015                    address: alt_addr,
1016                    amount: Some(30_000),
1017                    change: false,
1018                },
1019                TxOutputDesc {
1020                    address: change_addr,
1021                    amount: None,
1022                    change: true,
1023                },
1024            ],
1025            target_zeros: 0,
1026            start_nonce: Some(0),
1027            batch_size: Some(4),
1028            distribution: Some(distribution.clone()),
1029        };
1030
1031        let mut progress_called = false;
1032        let mut found_called = false;
1033
1034        let result = miner
1035            .mine_transaction(
1036                params,
1037                Some(|stats: ProgressStats| {
1038                    progress_called = true;
1039                    assert!(stats.hashes_processed >= 1);
1040                    assert_eq!(stats.last_nonce, Some(0));
1041                }),
1042                Some(|_: &MineResult| found_called = true),
1043            )
1044            .expect("mining succeeds with distribution");
1045
1046        assert!(progress_called, "progress callback should fire");
1047        assert!(found_called, "found callback should fire");
1048        assert_eq!(result.nonce, 0);
1049        assert!(result.attempts >= 1);
1050
1051        let psbt = Psbt::from_str(&result.psbt).expect("psbt parses");
1052        let expected_op_return = create_zeld_distribution_op_return(&distribution, result.nonce);
1053
1054        assert!(
1055            psbt.unsigned_tx
1056                .output
1057                .iter()
1058                .any(|o| o.script_pubkey.as_bytes() == expected_op_return),
1059            "psbt must include ZELD distribution OP_RETURN"
1060        );
1061    }
1062
1063    #[cfg(not(feature = "gpu"))]
1064    #[test]
1065    fn gpu_flag_falls_back_without_feature() {
1066        let result = mine_once(true);
1067        assert_eq!(result.nonce, 0);
1068    }
1069
1070    #[cfg(feature = "gpu")]
1071    #[test]
1072    fn gpu_feature_path_runs_or_falls_back() {
1073        let result = mine_once(true);
1074        assert_eq!(result.nonce, 0);
1075
1076        let psbt = Psbt::from_str(&result.psbt).expect("psbt parses");
1077        let txid = psbt.unsigned_tx.compute_txid().to_string();
1078        assert_eq!(txid, result.txid);
1079    }
1080
1081    #[test]
1082    fn mines_successfully_when_change_is_dust() {
1083        // Craft amounts so that change ends up below dust limit
1084        // P2WPKH dust limit is 310 sats
1085        // For a tx with 1 input, 2 outputs (user + change) + OP_RETURN:
1086        // vsize ~ 129 vbytes at 2 sats/vB = 258 sats fee
1087        // With input=10000, user=9500, fee~258, change would be ~242 (below dust limit of 310)
1088        let miner = ZeldMiner::new(ZeldMinerOptions {
1089            network: NetworkOption::Mainnet,
1090            batch_size: 4,
1091            use_gpu: false,
1092            worker_threads: 1,
1093            sats_per_vbyte: 2,
1094        })
1095        .expect("miner builds");
1096
1097        let (user_addr, change_addr) = sample_addresses();
1098
1099        let params = MineParams {
1100            inputs: vec![sample_input_desc(10_000)],
1101            outputs: vec![
1102                TxOutputDesc {
1103                    address: user_addr,
1104                    amount: Some(9_500), // Leave ~500 sats for fee + dust change
1105                    change: false,
1106                },
1107                TxOutputDesc {
1108                    address: change_addr,
1109                    amount: None,
1110                    change: true,
1111                },
1112            ],
1113            target_zeros: 0,
1114            start_nonce: Some(0),
1115            batch_size: Some(4),
1116            distribution: None,
1117        };
1118
1119        let result = miner
1120            .mine_transaction(params, None::<fn(ProgressStats)>, None::<fn(&MineResult)>)
1121            .expect("mining should succeed even when change is dust");
1122
1123        // PSBT should parse and only have 2 outputs: user + OP_RETURN (no change)
1124        let psbt = Psbt::from_str(&result.psbt).expect("psbt parses");
1125        assert_eq!(
1126            psbt.unsigned_tx.output.len(),
1127            2,
1128            "should have user output + OP_RETURN only (no change)"
1129        );
1130
1131        // Verify the txid matches
1132        let txid = psbt.unsigned_tx.compute_txid().to_string();
1133        assert_eq!(txid, result.txid);
1134    }
1135}