Skip to main content

zeldhash_miner_wasm/
lib.rs

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(); // store little-endian for serialization
70    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/// Mine across a nonce range, automatically splitting at byte-length boundaries.
297#[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// ---------------- GPU bindings (feature-gated) ----------------
482#[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]
509/// Dispatch a GPU batch and return the match with the smallest nonce (OP_RETURN value).
510pub 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            // Multiple matches can occur; pick the one with the smallest nonce to keep
570            // OP_RETURN minimal and deterministic for callers.
571            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// Stub for non-GPU builds to keep the JS API predictable.
729#[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// ---------------- Tests ----------------
763#[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; // 255 -> 1-byte minimal encoding
805        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]); // minimal encoding
811            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        // Range crosses 0xff -> 0x100 so byte length would change.
826        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); // up to 0xff
836        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        // Range would overflow u64.
844        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        // Pre-computed nonce for prefix "p", suffix "s" that yields 4 leading zeros.
850        // Deterministic for current hashing.
851        const TARGET_NONCE: u64 = 63_372;
852        let prefix = b"p";
853        let suffix = b"s";
854        let batch_size = 70_000u32; // covers the expected nonce
855
856        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        // Simulate a worker hashing 500k nonces in 2 seconds; should clear 200 KH/s.
876        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        // Build a simple P2WPKH scriptPubKey directly (avoids wasm/js bindings in native tests).
883        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, // ignore fees for this focused test
908        };
909
910        let outputs_before = vec![user_output.clone(), change_output.clone()];
911        let outputs_after = Vec::new();
912
913        // Build prefix/suffix around the OP_RETURN placeholder of the known nonce length.
914        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        // Construct a PSBT using the same outputs (including OP_RETURN and change) and verify txid.
930        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            // Nonce 0xff encodes to 1 byte; the builder should use the minimal encoding automatically.
1039            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            // Ordering: user output, OP_RETURN, change
1044            let op_return = &psbt.unsigned_tx.output[1].script_pubkey;
1045            assert_eq!(op_return.as_bytes(), &[0x6a, 0x01, 0xff]);
1046        }
1047    }
1048}