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
27const 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
148pub 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 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(¶ms.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 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 self.control.wait_if_paused_or_stopped()?;
359 continue;
360 }
361 } else {
362 }
364 }
365 }
366 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 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 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), 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 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 let txid = psbt.unsigned_tx.compute_txid().to_string();
1133 assert_eq!(txid, result.txid);
1134 }
1135}