1use js_sys::{Array, BigInt, Object, Reflect, Uint8Array};
2use serde::{Deserialize, Serialize};
3use wasm_bindgen::{prelude::*, JsCast};
4
5#[cfg(feature = "gpu")]
6use std::cell::RefCell;
7
8use zeldhash_miner_core::{
9 build_mining_template as core_build_mining_template, build_psbt_from_plan, double_sha256,
10 encode_cbor_uint, encode_nonce, mine_batch_with_cbor, nonce_len_for_range,
11 parse_address_for_network, split_nonce_segments, split_nonce_segments_cbor, txid_to_hex,
12 Network, OutputRequest, TransactionPlan, TxInput,
13};
14
15#[cfg(test)]
16use zeldhash_miner_core::mine_batch;
17
18#[cfg(feature = "gpu")]
19use zeldhash_miner_gpu::{
20 calibrate_batch_size as gpu_calibrate_batch_size, dispatch_mining_batch, GpuContext,
21 MineResult as GpuMineResult, MiningBatch,
22};
23
24#[cfg(feature = "gpu")]
25thread_local! {
26 static GPU_CTX: RefCell<Option<GpuContext>> = const { RefCell::new(None) };
27}
28
29fn network_from_str(network: &str) -> Option<Network> {
30 match network.to_ascii_lowercase().as_str() {
31 "mainnet" => Some(Network::Mainnet),
32 "testnet" | "signet" => Some(Network::Testnet),
33 "regtest" => Some(Network::Regtest),
34 _ => None,
35 }
36}
37
38fn default_sequence() -> u32 {
39 zeldhash_miner_core::tx::DEFAULT_SEQUENCE
40}
41
42fn js_error(msg: impl AsRef<str>) -> JsValue {
43 JsValue::from_str(msg.as_ref())
44}
45
46#[derive(Debug, Deserialize, Serialize)]
47struct JsInput {
48 txid: String,
49 vout: u32,
50 #[serde(rename = "scriptPubKey")]
51 script_pubkey: String,
52 amount: u64,
53 #[serde(default = "default_sequence")]
54 sequence: u32,
55}
56
57#[derive(Debug, Deserialize, Serialize)]
58struct JsOutput {
59 address: String,
60 amount: Option<u64>,
61 change: bool,
62}
63
64fn decode_txid_hex(txid: &str) -> Result<[u8; 32], String> {
65 let mut bytes = hex::decode(txid).map_err(|_| "invalid txid hex".to_string())?;
66 if bytes.len() != 32 {
67 return Err("txid must be 32 bytes".to_string());
68 }
69 bytes.reverse(); let mut out = [0u8; 32];
71 out.copy_from_slice(&bytes);
72 Ok(out)
73}
74
75fn decode_hex_bytes(data: &str) -> Result<Vec<u8>, String> {
76 hex::decode(data).map_err(|_| "invalid hex string".to_string())
77}
78
79const JS_MAX_SAFE_INTEGER: f64 = 9_007_199_254_740_991.0;
80
81#[derive(Debug, Deserialize, Serialize, Clone, Copy)]
82struct NonceRange {
83 #[serde(rename = "startNonce")]
84 start: u64,
85 #[serde(rename = "batchSize")]
86 size: u32,
87}
88
89fn parse_nonce_range(range: JsValue) -> Result<NonceRange, JsValue> {
90 serde_wasm_bindgen::from_value(range).map_err(|e| js_error(format!("invalid nonce range: {e}")))
91}
92
93fn parse_distribution(js: JsValue) -> Result<Option<Vec<u64>>, JsValue> {
94 if js.is_null() || js.is_undefined() {
95 return Ok(None);
96 }
97
98 if !js.is_object() || !Array::is_array(&js) {
99 return Err(js_error(
100 "distribution must be an array of integers (number or BigInt)",
101 ));
102 }
103
104 let arr = Array::from(&js);
105 let mut dist = Vec::with_capacity(arr.length() as usize);
106
107 for value in arr.iter() {
108 let parsed = if let Some(bi) = value.dyn_ref::<js_sys::BigInt>() {
109 let s = bi
110 .to_string(10)
111 .map_err(|_| js_error("failed to convert distribution BigInt to string"))?
112 .as_string()
113 .ok_or_else(|| js_error("failed to convert distribution BigInt to string"))?;
114 s.parse::<u64>()
115 .map_err(|_| js_error("distribution values must fit in u64"))?
116 } else if let Some(num) = value.as_f64() {
117 if !num.is_finite() || num.fract() != 0.0 || num < 0.0 {
118 return Err(js_error(
119 "distribution values must be non-negative integers",
120 ));
121 }
122 if num > JS_MAX_SAFE_INTEGER {
123 return Err(js_error(
124 "distribution values above 2^53 must be provided as BigInt",
125 ));
126 }
127 num as u64
128 } else {
129 return Err(js_error(
130 "distribution values must be integers (number or BigInt)",
131 ));
132 };
133
134 dist.push(parsed);
135 }
136
137 Ok(Some(dist))
138}
139
140fn parse_inputs(js_inputs: Vec<JsInput>) -> Result<Vec<TxInput>, String> {
141 let mut inputs = Vec::with_capacity(js_inputs.len());
142 for input in js_inputs {
143 let txid = decode_txid_hex(&input.txid)?;
144 let script_pubkey = decode_hex_bytes(&input.script_pubkey)?;
145 inputs.push(TxInput {
146 txid,
147 vout: input.vout,
148 script_pubkey,
149 amount: input.amount,
150 sequence: input.sequence,
151 });
152 }
153 Ok(inputs)
154}
155
156fn plan_transaction(
157 inputs: JsValue,
158 outputs: JsValue,
159 network: &str,
160 sats_per_vbyte: u64,
161 op_return_payload: &[u8],
162 distribution: Option<&[u64]>,
163) -> Result<TransactionPlan, String> {
164 let js_inputs: Vec<JsInput> =
165 serde_wasm_bindgen::from_value(inputs).map_err(|e| format!("invalid inputs: {e}"))?;
166 let js_outputs: Vec<JsOutput> =
167 serde_wasm_bindgen::from_value(outputs).map_err(|e| format!("invalid outputs: {e}"))?;
168
169 let network = network_from_str(network).ok_or_else(|| "unsupported network".to_string())?;
170
171 let inputs = parse_inputs(js_inputs)?;
172 let output_requests: Vec<OutputRequest> = js_outputs
173 .into_iter()
174 .map(|o| OutputRequest {
175 address: o.address,
176 amount: o.amount,
177 change: o.change,
178 })
179 .collect();
180
181 zeldhash_miner_core::plan_transaction(
182 inputs,
183 output_requests,
184 network,
185 sats_per_vbyte,
186 op_return_payload,
187 distribution,
188 )
189 .map_err(|e| e.to_string())
190}
191
192fn build_psbt_inner(
193 inputs: JsValue,
194 outputs: JsValue,
195 network: &str,
196 sats_per_vbyte: u64,
197 nonce: u64,
198 distribution: Option<Vec<u64>>,
199) -> Result<String, String> {
200 let nonce_bytes = if distribution.is_some() {
201 encode_cbor_uint(nonce)
202 } else {
203 encode_nonce(nonce)
204 };
205
206 let plan = plan_transaction(
207 inputs,
208 outputs,
209 network,
210 sats_per_vbyte,
211 &nonce_bytes,
212 distribution.as_deref(),
213 )?;
214 let (psbt_b64, _) = build_psbt_from_plan(&plan).map_err(|e| e.to_string())?;
215 Ok(psbt_b64)
216}
217
218fn make_result_object(nonce: u64, txid: &[u8; 32]) -> JsValue {
219 let obj = Object::new();
220 let nonce_bigint = BigInt::new(&JsValue::from_str(&nonce.to_string()))
221 .expect("u64 to BigInt conversion should succeed");
222 let _ = Reflect::set(&obj, &"nonce".into(), &nonce_bigint.into());
223 let txid_hex = txid_to_hex(txid);
224 let _ = Reflect::set(&obj, &"txid".into(), &JsValue::from_str(&txid_hex));
225 obj.into()
226}
227
228fn make_validation_object(ok: bool, message: Option<String>) -> JsValue {
229 let obj = Object::new();
230 let _ = Reflect::set(&obj, &"ok".into(), &JsValue::from_bool(ok));
231 if let Some(msg) = message {
232 let _ = Reflect::set(&obj, &"error".into(), &JsValue::from_str(&msg));
233 }
234 obj.into()
235}
236
237#[wasm_bindgen]
238pub fn init_panic_hook() {
239 console_error_panic_hook::set_once();
240}
241
242#[cfg(feature = "gpu")]
243fn with_gpu_ctx<F, R>(f: F) -> Option<R>
244where
245 F: FnOnce(&GpuContext) -> R,
246{
247 GPU_CTX.with(|ctx| ctx.borrow().as_ref().map(f))
248}
249
250#[wasm_bindgen]
251pub fn mine_batch_wasm(
252 tx_prefix: &[u8],
253 tx_suffix: &[u8],
254 start_nonce: u64,
255 batch_size: u32,
256 target_zeros: u8,
257 use_cbor_nonce: bool,
258) -> JsValue {
259 match mine_batch_with_cbor(
260 tx_prefix,
261 tx_suffix,
262 start_nonce,
263 batch_size,
264 target_zeros,
265 use_cbor_nonce,
266 ) {
267 Ok(Some(res)) => make_result_object(res.nonce, &res.txid),
268 Ok(None) => JsValue::NULL,
269 Err(err) => make_validation_object(false, Some(err.to_string())),
270 }
271}
272
273fn build_template_for_nonce_len(
274 inputs: JsValue,
275 outputs: JsValue,
276 network: &str,
277 sats_per_vbyte: u64,
278 nonce_len: usize,
279 distribution: Option<&[u64]>,
280) -> Result<(Vec<u8>, Vec<u8>), JsValue> {
281 let placeholder = vec![0u8; nonce_len];
282 let plan = plan_transaction(
283 inputs,
284 outputs,
285 network,
286 sats_per_vbyte,
287 &placeholder,
288 distribution,
289 )
290 .map_err(js_error)?;
291 let template =
292 core_build_mining_template(&plan, nonce_len).map_err(|e| js_error(e.to_string()))?;
293 Ok((template.prefix, template.suffix))
294}
295
296#[wasm_bindgen]
298pub fn mine_range_wasm(
299 inputs: JsValue,
300 outputs: JsValue,
301 network: &str,
302 sats_per_vbyte: u64,
303 range: JsValue,
304 target_zeros: u8,
305 distribution: JsValue,
306) -> JsValue {
307 let range = match parse_nonce_range(range) {
308 Ok(r) => r,
309 Err(err) => return err,
310 };
311
312 let distribution = match parse_distribution(distribution) {
313 Ok(d) => d,
314 Err(err) => return err,
315 };
316
317 let use_cbor_nonce = distribution.is_some();
318 let segments = if use_cbor_nonce {
319 match split_nonce_segments_cbor(range.start, range.size) {
320 Ok(s) => s,
321 Err(err) => return make_validation_object(false, Some(err.to_string())),
322 }
323 } else {
324 match split_nonce_segments(range.start, range.size) {
325 Ok(s) => s,
326 Err(err) => return make_validation_object(false, Some(err.to_string())),
327 }
328 };
329
330 for segment in segments {
331 let (prefix, suffix) = match build_template_for_nonce_len(
332 inputs.clone(),
333 outputs.clone(),
334 network,
335 sats_per_vbyte,
336 segment.nonce_len as usize,
337 distribution.as_deref(),
338 ) {
339 Ok(ps) => ps,
340 Err(err) => return err,
341 };
342
343 match mine_batch_with_cbor(
344 &prefix,
345 &suffix,
346 segment.start,
347 segment.size,
348 target_zeros,
349 use_cbor_nonce,
350 ) {
351 Ok(Some(res)) => return make_result_object(res.nonce, &res.txid),
352 Ok(None) => continue,
353 Err(err) => return make_validation_object(false, Some(err.to_string())),
354 }
355 }
356
357 JsValue::NULL
358}
359
360#[wasm_bindgen]
361pub fn validate_address(addr: &str, network: &str) -> JsValue {
362 let network = match network_from_str(network) {
363 Some(net) => net,
364 None => return make_validation_object(false, Some("unsupported network".to_string())),
365 };
366
367 match parse_address_for_network(addr, Some(network)) {
368 Ok(parsed) => {
369 let obj = Object::new();
370 let _ = Reflect::set(&obj, &"ok".into(), &JsValue::from_bool(true));
371 let _ = Reflect::set(
372 &obj,
373 &"addressType".into(),
374 &JsValue::from_str(match parsed.address_type {
375 zeldhash_miner_core::AddressType::P2WPKH => "p2wpkh",
376 zeldhash_miner_core::AddressType::P2TR => "p2tr",
377 }),
378 );
379 let _ = Reflect::set(
380 &obj,
381 &"network".into(),
382 &JsValue::from_str(match parsed.network {
383 Network::Mainnet => "mainnet",
384 Network::Testnet => "testnet",
385 Network::Regtest => "regtest",
386 }),
387 );
388 obj.into()
389 }
390 Err(err) => make_validation_object(false, Some(err.to_string())),
391 }
392}
393
394#[wasm_bindgen]
395pub fn build_psbt(
396 inputs: JsValue,
397 outputs: JsValue,
398 network: &str,
399 sats_per_vbyte: u64,
400 nonce: u64,
401 distribution: JsValue,
402) -> Result<String, JsValue> {
403 let distribution = parse_distribution(distribution)?;
404
405 build_psbt_inner(
406 inputs,
407 outputs,
408 network,
409 sats_per_vbyte,
410 nonce,
411 distribution,
412 )
413 .map_err(js_error)
414}
415
416#[wasm_bindgen]
417pub fn build_mining_template(
418 inputs: JsValue,
419 outputs: JsValue,
420 network: &str,
421 sats_per_vbyte: u64,
422 start_nonce: u64,
423 batch_size: u32,
424 distribution: JsValue,
425) -> Result<JsValue, JsValue> {
426 let distribution = parse_distribution(distribution)?;
427 let use_cbor_nonce = distribution.is_some();
428
429 let nonce_len = if use_cbor_nonce {
430 let segments = split_nonce_segments_cbor(start_nonce, batch_size).map_err(js_error)?;
431 if segments.len() != 1 {
432 return Err(js_error(
433 "nonce range crosses CBOR length boundary; reduce batch size",
434 ));
435 }
436 segments[0].nonce_len as usize
437 } else {
438 nonce_len_for_range(start_nonce, batch_size).map_err(js_error)? as usize
439 };
440
441 let placeholder = vec![0u8; nonce_len];
442 let plan = plan_transaction(
443 inputs,
444 outputs,
445 network,
446 sats_per_vbyte,
447 &placeholder,
448 distribution.as_deref(),
449 )
450 .map_err(js_error)?;
451 let template =
452 core_build_mining_template(&plan, nonce_len).map_err(|e| js_error(e.to_string()))?;
453 let prefix = template.prefix;
454 let suffix = template.suffix;
455
456 let obj = Object::new();
457 let _ = Reflect::set(
458 &obj,
459 &"prefix".into(),
460 &Uint8Array::from(prefix.as_slice()).into(),
461 );
462 let _ = Reflect::set(
463 &obj,
464 &"suffix".into(),
465 &Uint8Array::from(suffix.as_slice()).into(),
466 );
467 let _ = Reflect::set(
468 &obj,
469 &"useCborNonce".into(),
470 &JsValue::from_bool(use_cbor_nonce),
471 );
472 Ok(obj.into())
473}
474
475#[wasm_bindgen]
476pub fn compute_txid(tx_bytes: &[u8]) -> String {
477 let hash = double_sha256(tx_bytes);
478 txid_to_hex(&hash)
479}
480
481#[cfg(feature = "gpu")]
483#[wasm_bindgen]
484pub async fn init_gpu() -> Result<JsValue, JsValue> {
485 let ctx = GpuContext::init()
486 .await
487 .map_err(|e| JsValue::from_str(&e.to_string()))?;
488 let summary = ctx.adapter_summary();
489
490 let obj = Object::new();
491 let _ = Reflect::set(&obj, &"name".into(), &JsValue::from_str(&summary.name));
492 let _ = Reflect::set(
493 &obj,
494 &"backend".into(),
495 &JsValue::from_str(&summary.backend),
496 );
497 let _ = Reflect::set(
498 &obj,
499 &"deviceType".into(),
500 &JsValue::from_str(&summary.device_type),
501 );
502
503 GPU_CTX.with(|slot| *slot.borrow_mut() = Some(ctx));
504 Ok(obj.into())
505}
506
507#[cfg(feature = "gpu")]
508#[wasm_bindgen]
509pub async fn mine_batch_gpu(
511 tx_prefix: &[u8],
512 tx_suffix: &[u8],
513 start_nonce: u64,
514 batch_size: u32,
515 target_zeros: u8,
516 use_cbor_nonce: bool,
517) -> JsValue {
518 let length_check = if use_cbor_nonce {
519 split_nonce_segments_cbor(start_nonce, batch_size)
520 } else {
521 split_nonce_segments(start_nonce, batch_size)
522 };
523
524 match length_check {
525 Ok(segs) if segs.len() == 1 => {}
526 Ok(_) => {
527 let msg = if use_cbor_nonce {
528 "nonce range crosses CBOR length boundary; reduce batch size".to_string()
529 } else {
530 "nonce range crosses byte-length boundary; reduce batch size".to_string()
531 };
532 return make_validation_object(false, Some(msg));
533 }
534 Err(err) => return make_validation_object(false, Some(err.to_string())),
535 }
536
537 let cpu_fallback = || {
538 mine_batch_with_cbor(
539 tx_prefix,
540 tx_suffix,
541 start_nonce,
542 batch_size,
543 target_zeros,
544 use_cbor_nonce,
545 )
546 .map(|maybe| match maybe {
547 Some(res) => make_result_object(res.nonce, &res.txid),
548 None => JsValue::NULL,
549 })
550 .unwrap_or_else(|err| make_validation_object(false, Some(err.to_string())))
551 };
552
553 let ctx = match with_gpu_ctx(|c| c.clone()) {
554 Some(ctx) => ctx,
555 None => return cpu_fallback(),
556 };
557
558 let batch = MiningBatch {
559 tx_prefix,
560 tx_suffix,
561 start_nonce,
562 batch_size,
563 target_zeros,
564 use_cbor_nonce,
565 };
566
567 match dispatch_mining_batch(&ctx, &batch).await {
568 Ok(results) => {
569 if let Some(best) = results.iter().min_by_key(|r| r.nonce) {
572 make_result_object(best.nonce, &best.txid)
573 } else {
574 JsValue::NULL
575 }
576 }
577 Err(_) => cpu_fallback(),
578 }
579}
580
581#[cfg(feature = "gpu")]
582async fn mine_segment_gpu_or_cpu(
583 ctx: Option<GpuContext>,
584 prefix: Vec<u8>,
585 suffix: Vec<u8>,
586 start_nonce: u64,
587 batch_size: u32,
588 target_zeros: u8,
589 use_cbor_nonce: bool,
590) -> JsValue {
591 let cpu_path = || {
592 mine_batch_with_cbor(
593 &prefix,
594 &suffix,
595 start_nonce,
596 batch_size,
597 target_zeros,
598 use_cbor_nonce,
599 )
600 .map(|maybe| match maybe {
601 Some(res) => make_result_object(res.nonce, &res.txid),
602 None => JsValue::NULL,
603 })
604 .unwrap_or_else(|err| make_validation_object(false, Some(err.to_string())))
605 };
606
607 let ctx = match ctx {
608 Some(c) => c,
609 None => return cpu_path(),
610 };
611
612 let batch = MiningBatch {
613 tx_prefix: &prefix,
614 tx_suffix: &suffix,
615 start_nonce,
616 batch_size,
617 target_zeros,
618 use_cbor_nonce,
619 };
620
621 match dispatch_mining_batch(&ctx, &batch).await {
622 Ok(results) => {
623 if let Some(best) = select_best_result(&results) {
624 make_result_object(best.nonce, &best.txid)
625 } else {
626 JsValue::NULL
627 }
628 }
629 Err(_) => cpu_path(),
630 }
631}
632
633#[cfg(feature = "gpu")]
634fn select_best_result(results: &[GpuMineResult]) -> Option<GpuMineResult> {
635 results.iter().min_by_key(|r| r.nonce).cloned()
636}
637
638#[cfg(feature = "gpu")]
639async fn mine_range_segments_gpu(
640 inputs: JsValue,
641 outputs: JsValue,
642 network: &str,
643 sats_per_vbyte: u64,
644 range: &NonceRange,
645 target_zeros: u8,
646 distribution: Option<&[u64]>,
647) -> JsValue {
648 let use_cbor_nonce = distribution.is_some();
649 let segments = if use_cbor_nonce {
650 match split_nonce_segments_cbor(range.start, range.size) {
651 Ok(s) => s,
652 Err(err) => return make_validation_object(false, Some(err.to_string())),
653 }
654 } else {
655 match split_nonce_segments(range.start, range.size) {
656 Ok(s) => s,
657 Err(err) => return make_validation_object(false, Some(err.to_string())),
658 }
659 };
660
661 let ctx = with_gpu_ctx(|c| c.clone());
662
663 for segment in segments {
664 let (prefix, suffix) = match build_template_for_nonce_len(
665 inputs.clone(),
666 outputs.clone(),
667 network,
668 sats_per_vbyte,
669 segment.nonce_len as usize,
670 distribution,
671 ) {
672 Ok(ps) => ps,
673 Err(err) => return err,
674 };
675
676 let res = mine_segment_gpu_or_cpu(
677 ctx.clone(),
678 prefix,
679 suffix,
680 segment.start,
681 segment.size,
682 target_zeros,
683 use_cbor_nonce,
684 )
685 .await;
686
687 if !res.is_null() {
688 return res;
689 }
690 }
691
692 JsValue::NULL
693}
694
695#[cfg(feature = "gpu")]
696#[wasm_bindgen]
697pub async fn mine_range_gpu(
698 inputs: JsValue,
699 outputs: JsValue,
700 network: &str,
701 sats_per_vbyte: u64,
702 range: JsValue,
703 target_zeros: u8,
704 distribution: JsValue,
705) -> JsValue {
706 let range = match parse_nonce_range(range) {
707 Ok(r) => r,
708 Err(err) => return err,
709 };
710
711 let distribution = match parse_distribution(distribution) {
712 Ok(d) => d,
713 Err(err) => return err,
714 };
715
716 mine_range_segments_gpu(
717 inputs,
718 outputs,
719 network,
720 sats_per_vbyte,
721 &range,
722 target_zeros,
723 distribution.as_deref(),
724 )
725 .await
726}
727
728#[cfg(not(feature = "gpu"))]
730#[wasm_bindgen]
731pub async fn mine_range_gpu(
732 _inputs: JsValue,
733 _outputs: JsValue,
734 _network: &str,
735 _sats_per_vbyte: u64,
736 _range: JsValue,
737 _target_zeros: u8,
738 _distribution: JsValue,
739) -> JsValue {
740 make_validation_object(false, Some("GPU feature not enabled".to_string()))
741}
742
743#[cfg(feature = "gpu")]
744#[wasm_bindgen]
745pub async fn calibrate_batch_size() -> Result<u32, JsValue> {
746 let ctx = match with_gpu_ctx(|c| c.clone()) {
747 Some(ctx) => ctx,
748 None => return Err(JsValue::from_str("GPU not initialized")),
749 };
750
751 gpu_calibrate_batch_size(&ctx)
752 .await
753 .map_err(|e| JsValue::from_str(&e.to_string()))
754}
755
756#[cfg(not(feature = "gpu"))]
757#[wasm_bindgen]
758pub async fn calibrate_batch_size() -> Result<u32, JsValue> {
759 Err(JsValue::from_str("GPU feature not enabled"))
760}
761
762#[cfg(test)]
764mod tests {
765 use super::{double_sha256, mine_batch, nonce_len_for_range, split_nonce_segments};
766 use bitcoin::hashes::Hash;
767 use bitcoin::psbt::Psbt;
768 use std::{sync::mpsc, thread, time::Duration};
769 use zeldhash_miner_core::{
770 count_leading_zeros, create_op_return_script, create_psbt, encode_nonce,
771 split_tx_for_mining, MineResult, TxInput, TxOutput,
772 };
773
774 fn mine_range_segments(
775 prefix: &[u8],
776 suffix: &[u8],
777 start_nonce: u64,
778 batch_size: u32,
779 target_zeros: u8,
780 ) -> Option<MineResult> {
781 let segments = split_nonce_segments(start_nonce, batch_size)
782 .expect("segment splitting should succeed");
783 let mut best: Option<MineResult> = None;
784 for seg in segments {
785 if let Some(res) =
786 mine_batch(prefix, suffix, seg.start, seg.size, target_zeros).expect("segment mine")
787 {
788 if best.as_ref().map_or(true, |b| res.nonce < b.nonce) {
789 best = Some(res);
790 }
791 }
792 }
793 best
794 }
795
796 fn hashes_per_second(attempts: u64, elapsed: Duration) -> f64 {
797 attempts as f64 / elapsed.as_secs_f64()
798 }
799
800 #[test]
801 fn mine_batch_uses_minimal_nonce() {
802 let prefix = b"p";
803 let suffix = b"s";
804 let start_nonce = 0xffu64; let batch_size = 2;
806
807 let expected_bytes = {
808 let mut v = Vec::new();
809 v.extend_from_slice(prefix);
810 v.extend_from_slice(&[0xff]); v.extend_from_slice(suffix);
812 v
813 };
814 let expected_hash = double_sha256(&expected_bytes);
815
816 let result = mine_range_segments(prefix, suffix, start_nonce, batch_size, 0)
817 .expect("should find a result");
818
819 assert_eq!(result.nonce, start_nonce);
820 assert_eq!(result.txid, expected_hash);
821 }
822
823 #[test]
824 fn mine_batch_rejects_range_crossing_byte_boundary() {
825 let err = mine_batch(b"p", b"s", 0xff, 2, 0).unwrap_err();
827 assert!(err.contains("byte-length boundary"));
828 }
829
830 #[test]
831 fn splits_nonce_segments_across_boundaries() {
832 let segments = split_nonce_segments(0xf0, 0x20).expect("must split");
833 assert_eq!(segments.len(), 2);
834 assert_eq!(segments[0].start, 0xf0);
835 assert_eq!(segments[0].size, 16); assert_eq!(segments[0].nonce_len, 1);
837 assert_eq!(segments[1].start, 0x100);
838 assert_eq!(segments[1].nonce_len, 2);
839 }
840
841 #[test]
842 fn rejects_overflowing_nonce_range() {
843 assert!(mine_batch(b"", b"", u64::MAX, 2, 0).is_err());
845 }
846
847 #[test]
848 fn single_worker_finds_known_4_zero_hash() {
849 const TARGET_NONCE: u64 = 63_372;
852 let prefix = b"p";
853 let suffix = b"s";
854 let batch_size = 70_000u32; let result = mine_range_segments(prefix, suffix, 0, batch_size, 4)
857 .expect("worker should find a match");
858
859 let mut expected_bytes = Vec::new();
860 expected_bytes.extend_from_slice(prefix);
861 expected_bytes.extend_from_slice(&encode_nonce(TARGET_NONCE));
862 expected_bytes.extend_from_slice(suffix);
863 let expected_hash = double_sha256(&expected_bytes);
864
865 assert_eq!(result.nonce, TARGET_NONCE);
866 assert_eq!(result.txid, expected_hash);
867 assert!(
868 count_leading_zeros(&result.txid) >= 4,
869 "txid should have at least 4 leading hex zeros"
870 );
871 }
872
873 #[test]
874 fn worker_hash_rate_calculation_exceeds_200kh() {
875 let rate = hashes_per_second(500_000, Duration::from_secs(2));
877 assert!(rate >= 200_000.0, "expected at least 200 KH/s, got {rate}");
878 }
879
880 #[test]
881 fn end_to_end_psbt_includes_mined_nonce() {
882 let mut spk = vec![0x00, 0x14];
884 spk.extend_from_slice(&[0x11u8; 20]);
885
886 let input = TxInput {
887 txid: [0x11u8; 32],
888 vout: 0,
889 script_pubkey: spk.clone(),
890 amount: 100_000,
891 sequence: zeldhash_miner_core::tx::DEFAULT_SEQUENCE,
892 };
893 let user_output = TxOutput {
894 script_pubkey: spk.clone(),
895 amount: 50_000,
896 };
897
898 let start_nonce = 0u64;
899 let batch_size = 1u32;
900 let nonce_len = nonce_len_for_range(start_nonce, batch_size).expect("valid range") as usize;
901 let nonce_bytes = encode_nonce(start_nonce);
902 assert_eq!(nonce_bytes.len(), nonce_len);
903
904 let op_return_script = create_op_return_script(&nonce_bytes);
905 let change_output = TxOutput {
906 script_pubkey: spk,
907 amount: input.amount - user_output.amount, };
909
910 let outputs_before = vec![user_output.clone(), change_output.clone()];
911 let outputs_after = Vec::new();
912
913 let (prefix, suffix) =
915 split_tx_for_mining(&[input.clone()], &outputs_before, &outputs_after, nonce_len)
916 .expect("split succeeds");
917
918 let mined = mine_batch(&prefix, &suffix, start_nonce, batch_size, 0)
919 .expect("mine ok")
920 .expect("target_zeros=0 yields first nonce");
921
922 let mut full_tx = Vec::new();
923 full_tx.extend_from_slice(&prefix);
924 full_tx.extend_from_slice(&encode_nonce(mined.nonce));
925 full_tx.extend_from_slice(&suffix);
926 let txid = double_sha256(&full_tx);
927 assert_eq!(mined.txid, txid);
928
929 let psbt_outputs = vec![
931 user_output,
932 change_output,
933 TxOutput {
934 script_pubkey: op_return_script,
935 amount: 0,
936 },
937 ];
938 let psbt_bytes = create_psbt(&[input], &psbt_outputs).expect("psbt builds");
939 let psbt = Psbt::deserialize(&psbt_bytes).expect("psbt parses");
940
941 assert_eq!(
942 psbt.unsigned_tx
943 .compute_txid()
944 .to_raw_hash()
945 .to_byte_array(),
946 txid
947 );
948 let op_return_spk = psbt.unsigned_tx.output[2].script_pubkey.as_bytes();
949 assert_eq!(op_return_spk, &[0x6a, 0x01, 0x00]);
950 }
951
952 #[test]
953 fn coordinates_multi_worker_search_and_prefers_first_match() {
954 const TARGET_NONCE: u64 = 63_372;
955 let prefix = b"p".to_vec();
956 let suffix = b"s".to_vec();
957 let target_zeros = 4u8;
958
959 let (tx, rx) = mpsc::channel();
960 let mut handles = Vec::new();
961 let ranges = [(0u64, 70_000u32), (70_000u64, 70_000u32)];
962
963 for (start_nonce, batch_size) in ranges {
964 let tx = tx.clone();
965 let prefix = prefix.clone();
966 let suffix = suffix.clone();
967 handles.push(thread::spawn(move || {
968 let res =
969 mine_range_segments(&prefix, &suffix, start_nonce, batch_size, target_zeros);
970 let _ = tx.send(res);
971 }));
972 }
973 drop(tx);
974
975 let mut found = None;
976 for _ in 0..ranges.len() {
977 if let Ok(Some(hit)) = rx.recv_timeout(Duration::from_secs(2)) {
978 if found
979 .as_ref()
980 .map_or(true, |current: &MineResult| hit.nonce < current.nonce)
981 {
982 found = Some(hit);
983 }
984 }
985 }
986
987 for handle in handles {
988 handle.join().expect("worker thread should finish");
989 }
990
991 let found = found.expect("one of the workers must report a match");
992 assert_eq!(found.nonce, TARGET_NONCE);
993 assert!(
994 count_leading_zeros(&found.txid) >= target_zeros,
995 "winning txid should satisfy target"
996 );
997 }
998
999 #[cfg(target_arch = "wasm32")]
1000 mod wasm_only {
1001 use super::*;
1002 use super::{build_psbt_inner, JsInput, JsOutput};
1003 use bech32::{hrp, segwit};
1004 use bitcoin::psbt::Psbt;
1005 use serde_wasm_bindgen;
1006 use std::str::FromStr;
1007
1008 #[test]
1009 fn build_psbt_uses_minimal_nonce() {
1010 let program = vec![0x11u8; 20];
1011 let addr = segwit::encode(hrp::BC, segwit::VERSION_0, &program).unwrap();
1012
1013 let mut spk = vec![0x00, 0x14];
1014 spk.extend_from_slice(&program);
1015
1016 let inputs = vec![JsInput {
1017 txid: "11".repeat(32),
1018 vout: 0,
1019 script_pubkey: hex::encode(&spk),
1020 amount: 100_000,
1021 }];
1022 let outputs = vec![
1023 JsOutput {
1024 address: addr.clone(),
1025 amount: Some(50_000),
1026 change: false,
1027 },
1028 JsOutput {
1029 address: addr,
1030 amount: None,
1031 change: true,
1032 },
1033 ];
1034
1035 let inputs_js = serde_wasm_bindgen::to_value(&inputs).unwrap();
1036 let outputs_js = serde_wasm_bindgen::to_value(&outputs).unwrap();
1037
1038 let psbt_b64 = build_psbt_inner(inputs_js, outputs_js, "mainnet", 1, 0xff)
1040 .expect("psbt should build");
1041 let psbt = Psbt::from_str(&psbt_b64).expect("psbt must parse");
1042
1043 let op_return = &psbt.unsigned_tx.output[1].script_pubkey;
1045 assert_eq!(op_return.as_bytes(), &[0x6a, 0x01, 0xff]);
1046 }
1047 }
1048}