bhwi-wasm 0.1.0

WebAssembly bindings for bhwi, the Bitcoin hardware wallet interface
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
//! Browser hardware-wallet access through WebHID, WebUSB, and WebSerial.
//!
//! [`Client`] exposes JavaScript bindings; [`HWI`] adapts asynchronous wallet
//! operations to JavaScript errors. Browser operations require a `Window` and
//! the relevant `navigator` API, with device permission obtained through a
//! chooser, normally from a user gesture in a secure context. Native builds do
//! not supply a browser environment.

pub mod ledger;
pub mod pinserver;
pub mod webhid;
pub mod webserial;
pub mod webusb;

use std::str::FromStr;

use async_trait::async_trait;
use bhwi::bitbox::{BITBOX02_PID, BITBOX02_VID};
use bhwi::keepkey::{
    KEEPKEY_HID_DEVICE_ID, KEEPKEY_WEBUSB_DEVICE_ID, ManagementContext as KeepKeyManagementContext,
};
use bhwi::ledger::{LedgerWalletPolicy, Version};
use bhwi::miniscript::descriptor::WalletPolicy;
use bhwi::passphrase::HostPassphrase;
use bhwi::trezor::{
    HostPin, ManagementContext as TrezorManagementContext, TREZOR_DEVICE_ID, TREZOR_ONE_DEVICE_ID,
};
use bhwi::{coldcard::COLDCARD_DEVICE_ID, ledger::LEDGER_DEVICE_ID};
use bhwi_async::{
    DeviceContext, DisplayAddress, HWI as AsyncHWI, Jade, KeepKey, Ledger, Specter, Trezor,
    WalletRegistration, bitbox::BitBox, coldcard::Coldcard,
    transport::bitbox::hid::BitBoxTransportHID, transport::coldcard::hid::ColdcardTransportHID,
    transport::ledger::hid::LedgerTransportHID, transport::specter::SpecterTransport,
    transport::trezor::TrezorTransport,
};
use bitcoin::{
    Network,
    address::AddressType,
    base64::prelude::{BASE64_STANDARD, Engine as _},
    bip32::DerivationPath,
    psbt::Psbt,
};
use log::Level;
use pinserver::PinServer;
use wasm_bindgen::prelude::*;
use webhid::WebHidDevice;
use webserial::WebSerialDevice;

/// Initializes console logging and the browser panic hook.
///
/// Invalid log levels fall back to `Info`.
///
/// # Panics
///
/// Panics if the console logger cannot be initialized, including repeated
/// initialization after a logger has already been installed.
#[wasm_bindgen]
pub fn initialize_logging(level: &str) {
    console_error_panic_hook::set_once();
    // Attempt to parse the log level from the string, default to Info if invalid
    let log_level = Level::from_str(level).unwrap_or(Level::Info);

    console_log::init_with_level(log_level).expect("error initializing log");
}

fn wallet_registration_js(registration: WalletRegistration) -> Result<JsValue, JsValue> {
    let result = js_sys::Object::new();
    let (status, hmac) = match registration {
        WalletRegistration::Complete { hmac } => (
            "complete",
            hmac.map(|value| JsValue::from_str(&hex::encode(value)))
                .unwrap_or(JsValue::NULL),
        ),
        WalletRegistration::PendingUserConfirmation => ("pending_user_confirmation", JsValue::NULL),
    };
    js_sys::Reflect::set(&result, &"status".into(), &JsValue::from_str(status))?;
    js_sys::Reflect::set(&result, &"hmac".into(), &hmac)?;
    Ok(result.into())
}

/// Browser-facing wallet operations with JavaScript errors.
///
/// The blanket implementation adapts [`bhwi_async::HWI`] and reports its errors
/// as JavaScript strings. Support and required [`DeviceContext`] values vary
/// by backend; unsupported operations and missing context return errors.
/// Futures need not be `Send`, and the trait does not select an async runtime.
#[async_trait(?Send)]
pub trait HWI {
    /// Runs the backend's unlock operation for a parsed Bitcoin network.
    ///
    /// This may require on-device interaction and does not necessarily change
    /// a network configured when the client was constructed.
    async fn unlock(&mut self, network: &str) -> Result<(), JsValue>;
    /// Returns the current wallet's master fingerprint as a hexadecimal string.
    ///
    /// The name abbreviates master fingerprint, not manufacturer.
    async fn get_mfg(&mut self) -> Result<String, JsValue>;
    /// Derives an extended public key at `path`, optionally requesting on-device display.
    async fn get_xpub(&mut self, path: &str, display: bool) -> Result<String, JsValue>;
    /// Returns an address, requesting display and using any backend-required context.
    ///
    /// Descriptor display requires a BitBox, Ledger, or Specter policy context
    /// on those backends and is unsupported by Trezor and KeepKey.
    async fn display_address(
        &mut self,
        address: DisplayAddress,
        context: Option<DeviceContext>,
    ) -> Result<String, JsValue>;
    /// Registers a named wallet policy, possibly leaving user confirmation pending.
    ///
    /// Trezor and KeepKey do not support wallet registration.
    async fn register_wallet(
        &mut self,
        name: &str,
        policy: &str,
    ) -> Result<WalletRegistration, JsValue>;
    /// Signs a PSBT and returns the updated PSBT using any required policy context.
    ///
    /// Ledger requires a [`DeviceContext::Ledger`] even without an HMAC.
    async fn sign_tx(
        &mut self,
        psbt: Psbt,
        context: Option<DeviceContext>,
    ) -> Result<Psbt, JsValue>;
    /// Signs the UTF-8 message at `path` and returns a base64-encoded signature.
    ///
    /// The 65-byte payload contains the backend's header followed by its compact
    /// ECDSA signature; the header is not normalized as in the CLI.
    async fn sign_message(&mut self, message: &str, path: &str) -> Result<String, JsValue>;
    /// Returns the JavaScript information object described by [`Client::get_info`].
    async fn get_info(&mut self) -> Result<JsValue, JsValue>;
    /// Requests a PIN prompt and returns whether it was accepted.
    async fn prompt_pin(&mut self) -> Result<bool, JsValue>;
    /// Submits scrambled PIN keypad positions in a Trezor or KeepKey management context.
    async fn send_pin(&mut self, context: Option<DeviceContext>) -> Result<bool, JsValue>;
}

#[async_trait(?Send)]
impl<T: AsyncHWI> HWI for T {
    async fn unlock(&mut self, network: &str) -> Result<(), JsValue> {
        let n = Network::from_str(network).map_err(|e| JsValue::from_str(&e.to_string()))?;
        self.unlock(n)
            .await
            .map_err(|e| JsValue::from_str(&format!("Failed to unlock: {:?}", e)))
    }

    async fn get_mfg(&mut self) -> Result<String, JsValue> {
        self.get_master_fingerprint()
            .await
            .map(|fp| fp.to_string())
            .map_err(|e| JsValue::from_str(&format!("Failed to get fingerprint: {:?}", e)))
    }

    async fn get_xpub(&mut self, path: &str, display: bool) -> Result<String, JsValue> {
        let p = DerivationPath::from_str(path)
            .map_err(|e| JsValue::from_str(&format!("Failed to get fingerprint: {:?}", e)))?;
        self.get_extended_pubkey(p, display)
            .await
            .map(|xpub| xpub.to_string())
            .map_err(|e| JsValue::from_str(&format!("Failed to get fingerprint: {:?}", e)))
    }

    async fn display_address(
        &mut self,
        address: DisplayAddress,
        context: Option<DeviceContext>,
    ) -> Result<String, JsValue> {
        AsyncHWI::display_address(self, address, context)
            .await
            .map_err(|e| JsValue::from_str(&format!("Failed to display address: {:?}", e)))
    }

    async fn register_wallet(
        &mut self,
        name: &str,
        policy: &str,
    ) -> Result<WalletRegistration, JsValue> {
        AsyncHWI::register_wallet(self, name, policy)
            .await
            .map_err(|e| JsValue::from_str(&format!("Failed to register wallet: {:?}", e)))
    }

    async fn sign_tx(
        &mut self,
        psbt: Psbt,
        context: Option<DeviceContext>,
    ) -> Result<Psbt, JsValue> {
        AsyncHWI::sign_tx(self, psbt, context)
            .await
            .map_err(|e| JsValue::from_str(&format!("Failed to sign psbt: {:?}", e)))
    }

    async fn sign_message(&mut self, message: &str, path: &str) -> Result<String, JsValue> {
        let path = DerivationPath::from_str(path)
            .map_err(|e| JsValue::from_str(&format!("Invalid derivation path: {:?}", e)))?;
        let (header, signature) = AsyncHWI::sign_message(self, message.as_bytes(), path)
            .await
            .map_err(|e| JsValue::from_str(&format!("Failed to sign message: {:?}", e)))?;
        let mut payload = [0u8; 65];
        payload[0] = header;
        payload[1..].copy_from_slice(&signature.serialize_compact());
        Ok(BASE64_STANDARD.encode(payload))
    }

    async fn prompt_pin(&mut self) -> Result<bool, JsValue> {
        AsyncHWI::prompt_pin(self)
            .await
            .map_err(|e| JsValue::from_str(&format!("Failed to prompt PIN: {:?}", e)))
    }

    async fn send_pin(&mut self, context: Option<DeviceContext>) -> Result<bool, JsValue> {
        AsyncHWI::send_pin(self, context)
            .await
            .map_err(|e| JsValue::from_str(&format!("Failed to send PIN: {:?}", e)))
    }

    async fn get_info(&mut self) -> Result<JsValue, JsValue> {
        let info = AsyncHWI::get_info(self)
            .await
            .map_err(|e| JsValue::from_str(&format!("Failed to get info: {:?}", e)))?;
        let obj = js_sys::Object::new();
        js_sys::Reflect::set(&obj, &"version".into(), &JsValue::from_str(&info.version)).unwrap();
        let networks = js_sys::Array::new();
        for n in &info.networks {
            networks.push(&JsValue::from_str(match n {
                Network::Bitcoin => "bitcoin",
                _ => "testnet",
            }));
        }
        js_sys::Reflect::set(&obj, &"networks".into(), &networks).unwrap();
        js_sys::Reflect::set(
            &obj,
            &"firmware".into(),
            &match &info.firmware {
                Some(f) => JsValue::from_str(f),
                None => JsValue::NULL,
            },
        )
        .unwrap();
        js_sys::Reflect::set(
            &obj,
            &"needsPinSent".into(),
            &JsValue::from_bool(info.needs_pin_sent.unwrap_or(false)),
        )
        .unwrap();
        Ok(obj.into())
    }
}

/// A connected wallet backend using a browser transport.
#[allow(clippy::large_enum_variant)]
pub enum Device {
    /// A Ledger connection over WebHID.
    Ledger(
        /// The Ledger client and its HID framing transport.
        Ledger<LedgerTransportHID<webhid::WebHidDevice>>,
    ),
    /// A Coldcard connection over WebHID.
    Coldcard(
        /// The Coldcard client and its HID framing transport.
        Coldcard<ColdcardTransportHID<webhid::WebHidDevice>>,
    ),
    /// A Jade connection over WebSerial with browser PIN-server requests.
    Jade(
        /// The Jade client, serial port, and PIN-server client.
        Jade<WebSerialDevice, PinServer>,
    ),
    /// A Specter-DIY connection over WebSerial.
    Specter(
        /// The Specter-DIY client and its serial framing transport.
        Specter<SpecterTransport<WebSerialDevice>>,
    ),
    /// A BitBox02 connection over WebHID.
    BitBox(
        /// The BitBox02 client and its HID framing transport.
        BitBox<BitBoxTransportHID<webhid::WebHidDevice>>,
    ),
    /// A Trezor One connection over WebHID.
    TrezorOne(
        /// The Trezor client and its HID framing transport.
        Trezor<TrezorTransport<webhid::WebHidDevice>>,
    ),
    /// A Trezor connection over WebUSB.
    TrezorT(
        /// The Trezor client and its USB framing transport.
        Trezor<TrezorTransport<webusb::WebUsbDevice>>,
    ),
    /// A KeepKey connection over WebHID.
    KeepKeyHid(
        /// The KeepKey client and its HID framing transport.
        KeepKey<TrezorTransport<webhid::WebHidDevice>>,
    ),
    /// A KeepKey connection over WebUSB.
    KeepKeyWebUsb(
        /// The KeepKey client and its USB framing transport.
        KeepKey<TrezorTransport<webusb::WebUsbDevice>>,
    ),
}

impl<'a> AsRef<dyn HWI + 'a> for Device {
    fn as_ref(&self) -> &(dyn HWI + 'a) {
        match self {
            Device::Coldcard(l) => l,
            Device::Ledger(l) => l,
            Device::Jade(j) => j,
            Device::Specter(s) => s,
            Device::BitBox(b) => b,
            Device::TrezorOne(t) => t,
            Device::TrezorT(t) => t,
            Device::KeepKeyHid(k) => k,
            Device::KeepKeyWebUsb(k) => k,
        }
    }
}

impl<'a> AsMut<dyn HWI + 'a> for Device {
    fn as_mut(&mut self) -> &mut (dyn HWI + 'a) {
        match self {
            Device::Coldcard(l) => l,
            Device::Ledger(l) => l,
            Device::Jade(j) => j,
            Device::Specter(s) => s,
            Device::BitBox(b) => b,
            Device::TrezorOne(t) => t,
            Device::TrezorT(t) => t,
            Device::KeepKeyHid(k) => k,
            Device::KeepKeyWebUsb(k) => k,
        }
    }
}

/// A JavaScript-facing client holding at most one connected wallet.
///
/// Connection methods show a browser permission chooser and install a backend;
/// they do not unlock it. Call [`unlock`](Self::unlock) before operations that
/// require an unlocked session. A successful connection replaces the current
/// backend without explicitly closing it. A function-valued `on_close_cb` is
/// called with no arguments on the transport's disconnect events or after an
/// explicit close.
///
/// Operations return JavaScript string errors when no device is connected or
/// when the backend fails. Operation support, network selection, and required
/// policy data are device-specific.
#[derive(Default)]
#[wasm_bindgen]
pub struct Client {
    device: Option<Device>,
}

#[wasm_bindgen]
impl Client {
    /// Creates a client without a connected device.
    #[wasm_bindgen(constructor)]
    pub fn new() -> Client {
        Client { device: None }
    }

    /// Requests a Coldcard WebHID connection without starting its encrypted session.
    ///
    /// Use [`unlock`](Self::unlock) to establish the session.
    #[wasm_bindgen]
    pub async fn connect_coldcard(&mut self, on_close_cb: JsValue) -> Result<(), JsValue> {
        let device = WebHidDevice::get_webhid_device(
            Some("Coldcard"),
            COLDCARD_DEVICE_ID.vid,
            None,
            None,
            on_close_cb,
        )
        .await
        .ok_or(JsValue::from_str("Failed to connect to coldcard"))?;
        let mut rng = rand_core::OsRng;
        self.device = Some(Device::Coldcard(Coldcard::new(
            ColdcardTransportHID::new(device),
            &mut rng,
        )));
        Ok(())
    }

    /// Requests a BitBox02 WebHID connection configured for `network`.
    ///
    /// Pairing occurs during [`unlock`](Self::unlock), not connection. A
    /// function-valued `on_pairing_code_cb` receives the pairing-code string
    /// synchronously while unlock waits for on-device confirmation.
    #[wasm_bindgen]
    pub async fn connect_bitbox(
        &mut self,
        network: &str,
        on_close_cb: JsValue,
        on_pairing_code_cb: JsValue,
    ) -> Result<(), JsValue> {
        let network = Network::from_str(network).map_err(|e| JsValue::from_str(&e.to_string()))?;
        let device = WebHidDevice::get_webhid_device(
            Some("BitBox02"),
            BITBOX02_VID,
            Some(BITBOX02_PID),
            None,
            on_close_cb,
        )
        .await
        .ok_or(JsValue::from_str("Failed to connect to bitbox"))?;
        let mut bb = BitBox::new(BitBoxTransportHID::new(device), None).with_network(network);
        // The pairing code hook fires synchronously inside `unlock`; forward it to JS so the
        // website can render the code while the user confirms it on the device.
        bb.set_pairing_code_hook(Box::new(move |code| {
            if let Ok(f) = on_pairing_code_cb.clone().dyn_into::<js_sys::Function>() {
                let _ = f.call1(&JsValue::NULL, &JsValue::from_str(code));
            }
        }));
        self.device = Some(Device::BitBox(bb));
        Ok(())
    }

    /// Requests a Ledger WebHID connection without opening a Bitcoin app.
    ///
    /// Use [`unlock`](Self::unlock) to request the app for the desired network.
    #[wasm_bindgen]
    pub async fn connect_ledger(&mut self, on_close_cb: JsValue) -> Result<(), JsValue> {
        let device = WebHidDevice::get_webhid_device(
            Some("Ledger"),
            LEDGER_DEVICE_ID.vid,
            None,
            None,
            on_close_cb,
        )
        .await
        .ok_or(JsValue::from_str("Failed to connect to ledger"))?;
        self.device = Some(Device::Ledger(Ledger::new(LedgerTransportHID::new(device))));
        Ok(())
    }

    /// Requests a Trezor One WebHID connection configured for `network`.
    ///
    /// The optional host `passphrase` is NFKD-normalized and used when the
    /// session selects host entry; `None` then means an empty passphrase.
    /// Trezor defaults to on-device entry and limits host entry to 50 normalized
    /// UTF-8 bytes, not characters.
    #[wasm_bindgen]
    pub async fn connect_trezor_one(
        &mut self,
        network: &str,
        passphrase: Option<String>,
        on_close_cb: JsValue,
    ) -> Result<(), JsValue> {
        let network = Network::from_str(network).map_err(|e| JsValue::from_str(&e.to_string()))?;
        let device = WebHidDevice::get_webhid_device(
            None,
            TREZOR_ONE_DEVICE_ID.vid,
            TREZOR_ONE_DEVICE_ID.pid,
            TREZOR_ONE_DEVICE_ID.usage_page,
            on_close_cb,
        )
        .await
        .ok_or(JsValue::from_str("Failed to connect to trezor one"))?;
        self.device = Some(Device::TrezorOne(
            Trezor::new(TrezorTransport::new(device))
                .with_network(network)
                .with_passphrase(passphrase.map(HostPassphrase::new)),
        ));
        Ok(())
    }

    /// Requests a Trezor WebUSB connection configured for `network`.
    ///
    /// Passphrase entry defaults to the device; if the session selects host
    /// entry, this client supplies an empty passphrase.
    #[wasm_bindgen]
    pub async fn connect_trezor_t(
        &mut self,
        network: &str,
        on_close_cb: JsValue,
    ) -> Result<(), JsValue> {
        let network = Network::from_str(network).map_err(|e| JsValue::from_str(&e.to_string()))?;
        let device = webusb::WebUsbDevice::get_webusb_device(
            TREZOR_DEVICE_ID.vid,
            TREZOR_DEVICE_ID.pid,
            on_close_cb,
        )
        .await
        .ok_or(JsValue::from_str("Failed to connect to trezor"))?;
        self.device = Some(Device::TrezorT(
            Trezor::new(TrezorTransport::new(device)).with_network(network),
        ));
        Ok(())
    }

    /// Requests a KeepKey WebHID connection configured for `network`.
    ///
    /// KeepKey defaults to host passphrase entry. The optional `passphrase` is
    /// NFKD-normalized; `None` means empty. Host entry is limited to 50 normalized
    /// UTF-8 bytes, not characters.
    #[wasm_bindgen]
    pub async fn connect_keepkey_hid(
        &mut self,
        network: &str,
        passphrase: Option<String>,
        on_close_cb: JsValue,
    ) -> Result<(), JsValue> {
        let network = Network::from_str(network).map_err(|e| JsValue::from_str(&e.to_string()))?;
        let device = WebHidDevice::get_webhid_device(
            None,
            KEEPKEY_HID_DEVICE_ID.vid,
            KEEPKEY_HID_DEVICE_ID.pid,
            KEEPKEY_HID_DEVICE_ID.usage_page,
            on_close_cb,
        )
        .await
        .ok_or(JsValue::from_str("Failed to connect to keepkey via WebHID"))?;
        self.device = Some(Device::KeepKeyHid(
            KeepKey::new(TrezorTransport::new(device))
                .with_network(network)
                .with_passphrase(passphrase.map(HostPassphrase::new)),
        ));
        Ok(())
    }

    /// Requests a KeepKey WebUSB connection configured for `network`.
    ///
    /// KeepKey defaults to host passphrase entry. The optional `passphrase` is
    /// NFKD-normalized; `None` means empty. Host entry is limited to 50 normalized
    /// UTF-8 bytes, not characters.
    #[wasm_bindgen]
    pub async fn connect_keepkey_webusb(
        &mut self,
        network: &str,
        passphrase: Option<String>,
        on_close_cb: JsValue,
    ) -> Result<(), JsValue> {
        let network = Network::from_str(network).map_err(|e| JsValue::from_str(&e.to_string()))?;
        let device = webusb::WebUsbDevice::get_webusb_device(
            KEEPKEY_WEBUSB_DEVICE_ID.vid,
            KEEPKEY_WEBUSB_DEVICE_ID.pid,
            on_close_cb,
        )
        .await
        .ok_or(JsValue::from_str("Failed to connect to keepkey via WebUSB"))?;
        self.device = Some(Device::KeepKeyWebUsb(
            KeepKey::new(TrezorTransport::new(device))
                .with_network(network)
                .with_passphrase(passphrase.map(HostPassphrase::new)),
        ));
        Ok(())
    }

    /// Requests a Jade WebSerial connection at 115200 bits per second for `network`.
    ///
    /// Unlock may use browser fetch requests to Jade's PIN server.
    #[wasm_bindgen]
    pub async fn connect_jade(
        &mut self,
        network: &str,
        on_close_cb: JsValue,
    ) -> Result<(), JsValue> {
        let network = Network::from_str(network).map_err(|e| JsValue::from_str(&e.to_string()))?;
        let device = WebSerialDevice::get_webserial_device(115200, on_close_cb)
            .await
            .ok_or(JsValue::from_str("Failed to connect to jade"))?;
        self.device = Some(Device::Jade(Jade::new(network, device, PinServer {})));
        Ok(())
    }

    /// Requests a Specter-DIY WebSerial connection at 115200 bits per second.
    ///
    /// `network` validates network-bearing responses; it does not change the
    /// network selected on the device.
    #[wasm_bindgen]
    pub async fn connect_specter(
        &mut self,
        network: &str,
        on_close_cb: JsValue,
    ) -> Result<(), JsValue> {
        let network = Network::from_str(network).map_err(|e| JsValue::from_str(&e.to_string()))?;
        let device = WebSerialDevice::get_webserial_device(115200, on_close_cb)
            .await
            .ok_or(JsValue::from_str("Failed to connect to Specter-DIY"))?;
        // Specter-DIY selects its network on-device; this only validates responses.
        self.device = Some(Device::Specter(Specter::new(
            network,
            SpecterTransport::new(device),
        )));
        Ok(())
    }

    /// Removes a connected Specter-DIY backend and schedules closing its serial port.
    ///
    /// Other backends are left connected. Returns before the browser finishes
    /// closing the port.
    #[wasm_bindgen]
    pub fn disconnect_specter(&mut self) {
        if !matches!(&self.device, Some(Device::Specter(_))) {
            return;
        }
        let Some(Device::Specter(specter)) = self.device.take() else {
            unreachable!("checked Specter device before taking it");
        };
        let mut serial = specter.transport.into_inner();
        serial.close();
    }

    /// Runs the connected backend's unlock operation for a parsed Bitcoin network.
    ///
    /// This may wait for on-device interaction or pairing. For backends whose
    /// network is set during connection, it does not change that configuration.
    #[wasm_bindgen]
    pub async fn unlock(&mut self, network: &str) -> Result<(), JsValue> {
        match &mut self.device {
            Some(d) => d.as_mut().unlock(network).await,
            None => Err(JsValue::from_str("Device not connected")),
        }
    }

    /// Returns the current wallet's master fingerprint as eight hexadecimal digits.
    #[wasm_bindgen]
    pub async fn get_master_fingerprint(&mut self) -> Result<String, JsValue> {
        match &mut self.device {
            Some(d) => d.as_mut().get_mfg().await,
            None => Err(JsValue::from_str("Device not connected")),
        }
    }

    /// Requests a Trezor or KeepKey PIN prompt and returns whether it was accepted.
    ///
    /// Backends that do not need host PIN entry return an unsupported-operation error.
    #[wasm_bindgen]
    pub async fn prompt_pin(&mut self) -> Result<bool, JsValue> {
        match &mut self.device {
            Some(d) => d.as_mut().prompt_pin().await,
            None => Err(JsValue::from_str("Device not connected")),
        }
    }

    /// Sends scrambled PIN keypad positions to Trezor or KeepKey.
    ///
    /// `positions` identifies positions in the keypad displayed on the device,
    /// not literal PIN digits. Validation requires nonempty ASCII digits but
    /// does not check a 1–9 range. Other backends reject host PIN entry.
    #[wasm_bindgen]
    pub async fn send_pin(&mut self, positions: &str) -> Result<bool, JsValue> {
        let device = self
            .device
            .as_mut()
            .ok_or(JsValue::from_str("Device not connected"))?;
        let pin = HostPin::new(positions.to_owned())
            .map_err(|e| JsValue::from_str(&format!("Invalid PIN positions: {:?}", e)))?;
        let context = match &*device {
            Device::TrezorOne(_) | Device::TrezorT(_) => Some(DeviceContext::TrezorManagement(
                TrezorManagementContext::Pin(pin),
            )),
            Device::KeepKeyHid(_) | Device::KeepKeyWebUsb(_) => Some(
                DeviceContext::KeepKeyManagement(KeepKeyManagementContext::Pin(pin)),
            ),
            Device::Ledger(_)
            | Device::Coldcard(_)
            | Device::Jade(_)
            | Device::Specter(_)
            | Device::BitBox(_) => None,
        };
        device.as_mut().send_pin(context).await
    }

    /// Returns a JavaScript object containing device information.
    ///
    /// The keys are `version` (string), `networks` (array of strings), `firmware`
    /// (string or `null`), and `needsPinSent` (boolean). Networks map Bitcoin
    /// mainnet to `"bitcoin"` and every other network to `"testnet"`. Unreported
    /// PIN status becomes `false`. Specter-DIY has no information command and
    /// returns an unsupported-operation error.
    #[wasm_bindgen]
    pub async fn get_info(&mut self) -> Result<JsValue, JsValue> {
        match &mut self.device {
            Some(d) => d.as_mut().get_info().await,
            None => Err(JsValue::from_str("Device not connected")),
        }
    }

    /// Derives an extended public key at `path`, optionally requesting on-device display.
    ///
    /// Returns the encoded extended-public-key string. Display support varies
    /// by backend; Specter-DIY rejects `display = true`.
    #[wasm_bindgen]
    pub async fn get_extended_pubkey(
        &mut self,
        path: &str,
        display: bool,
    ) -> Result<String, JsValue> {
        match &mut self.device {
            Some(d) => d.as_mut().get_xpub(path, display).await,
            None => Err(JsValue::from_str("Device not connected")),
        }
    }

    /// Registers a named wallet policy and returns its JavaScript status object.
    ///
    /// The object has `status` equal to `"complete"` or
    /// `"pending_user_confirmation"`, and `hmac` equal to a hexadecimal token
    /// string or `null`. A completed registration may have no token; pending
    /// confirmation always has `hmac: null`. Trezor and KeepKey do not support
    /// registration.
    #[wasm_bindgen]
    pub async fn register_wallet(&mut self, name: &str, policy: &str) -> Result<JsValue, JsValue> {
        let registration = match &mut self.device {
            Some(d) => d.as_mut().register_wallet(name, policy).await,
            None => Err(JsValue::from_str("Device not connected")),
        }?;
        wallet_registration_js(registration)
    }

    /// Returns an address at `path`, optionally requesting on-device display.
    ///
    /// `address_format` accepts `"legacy"`, `"nested-segwit"`,
    /// `"native-segwit"`, or `"taproot"`. Omitting it uses the backend's default;
    /// supported formats and honoring `display` vary by backend. Specter-DIY
    /// requires `display = true` and does not support Taproot path display.
    #[wasm_bindgen]
    pub async fn display_address_by_path(
        &mut self,
        path: &str,
        display: bool,
        address_format: Option<String>,
    ) -> Result<String, JsValue> {
        let path = DerivationPath::from_str(path)
            .map_err(|e| JsValue::from_str(&format!("Invalid derivation path: {:?}", e)))?;
        let address_format = address_format
            .map(|f| match f.as_str() {
                "legacy" => Ok(AddressType::P2pkh),
                "nested-segwit" => Ok(AddressType::P2sh),
                "native-segwit" => Ok(AddressType::P2wpkh),
                "taproot" => Ok(AddressType::P2tr),
                _ => Err(JsValue::from_str(&format!(
                    "Invalid address format: {f}. Expected: legacy, nested-segwit, native-segwit, taproot"
                ))),
            })
            .transpose()?;
        let address = DisplayAddress::ByPath {
            path,
            display,
            address_format,
        };
        match &mut self.device {
            Some(d) => d.as_mut().display_address(address, None).await,
            None => Err(JsValue::from_str("Device not connected")),
        }
    }

    /// Returns a named wallet's address at `index` on its receive or change branch.
    ///
    /// BitBox02 and Specter-DIY require `wallet_descriptor`; Specter-DIY also
    /// requires `display = true`. Ledger requires both `wallet_descriptor` and
    /// `wallet_hmac_hex`, with the HMAC encoding 32 bytes as 64 hexadecimal
    /// characters. Supplying neither leaves Ledger without its required
    /// context and returns an error. Coldcard and Jade resolve the wallet by
    /// `descriptor_name` on-device and ignore those optional arguments.
    /// Trezor and KeepKey do not support descriptor address display.
    #[wasm_bindgen]
    pub async fn display_address_by_descriptor(
        &mut self,
        descriptor_name: &str,
        index: u32,
        change: bool,
        display: bool,
        wallet_hmac_hex: Option<String>,
        wallet_descriptor: Option<String>,
    ) -> Result<String, JsValue> {
        // BitBox and Specter-DIY need the descriptor to resolve an address; Ledger needs the
        // registered policy plus its hmac; Coldcard/Jade resolve the wallet by name on-device.
        let context = match &self.device {
            Some(Device::BitBox(_)) => {
                let desc = wallet_descriptor.ok_or_else(|| {
                    JsValue::from_str("BitBox descriptor address requires wallet_descriptor")
                })?;
                let policy: WalletPolicy = desc
                    .parse()
                    .map_err(|e| JsValue::from_str(&format!("Invalid wallet descriptor: {e}")))?;
                Some(DeviceContext::BitBox { policy })
            }
            Some(Device::Ledger(_)) => match (wallet_hmac_hex, wallet_descriptor) {
                (Some(hmac_hex), Some(desc)) => {
                    let hmac_bytes = hex::decode(&hmac_hex)
                        .map_err(|e| JsValue::from_str(&format!("Invalid hmac hex: {e}")))?;
                    let hmac: [u8; 32] = hmac_bytes
                        .try_into()
                        .map_err(|_| JsValue::from_str("hmac must be 32 bytes (64 hex chars)"))?;
                    let wallet_policy: WalletPolicy = desc.parse().map_err(|e| {
                        JsValue::from_str(&format!("Invalid wallet descriptor: {e}"))
                    })?;
                    let ledger_policy = LedgerWalletPolicy::new(
                        descriptor_name.to_string(),
                        Version::V2,
                        wallet_policy,
                    );
                    Some(DeviceContext::Ledger {
                        wallet_policy: ledger_policy,
                        wallet_hmac: Some(hmac),
                    })
                }
                (None, None) => None,
                _ => {
                    return Err(JsValue::from_str(
                        "Both wallet_hmac_hex and wallet_descriptor must be provided together",
                    ));
                }
            },
            Some(Device::Specter(_)) => {
                let desc = wallet_descriptor.ok_or_else(|| {
                    JsValue::from_str("Specter-DIY descriptor address requires wallet_descriptor")
                })?;
                let policy: WalletPolicy = desc
                    .parse()
                    .map_err(|e| JsValue::from_str(&format!("Invalid wallet descriptor: {e}")))?;
                Some(DeviceContext::Specter { policy })
            }
            _ => None,
        };
        let address = DisplayAddress::ByDescriptor {
            index,
            change,
            display,
            descriptor_name: descriptor_name.to_string(),
        };
        match &mut self.device {
            Some(d) => d.as_mut().display_address(address, context).await,
            None => Err(JsValue::from_str("Device not connected")),
        }
    }

    /// Signs a base64-encoded PSBT and returns the updated PSBT in base64.
    ///
    /// Leading and trailing whitespace in `psbt` is ignored. Ledger requires
    /// `policy_name` and `wallet_descriptor` together, even for a standard
    /// policy without an HMAC. Its optional `wallet_hmac_hex` must encode 32
    /// bytes as 64 hexadecimal characters and cannot be supplied without the
    /// policy. BitBox02 accepts `wallet_descriptor` for policy-based signing
    /// and otherwise infers single-signature inputs from the PSBT; it ignores
    /// the other policy arguments. Other backends ignore all three arguments.
    #[wasm_bindgen]
    pub async fn sign_psbt(
        &mut self,
        psbt: &str,
        policy_name: Option<String>,
        wallet_descriptor: Option<String>,
        wallet_hmac_hex: Option<String>,
    ) -> Result<String, JsValue> {
        let psbt = Psbt::from_str(psbt.trim())
            .map_err(|e| JsValue::from_str(&format!("Invalid PSBT: {e}")))?;
        // Ledger signs registered policies with name+descriptor+hmac; BitBox re-supplies the
        // policy descriptor for multisig signing; Coldcard/Jade resolve everything on-device.
        let context = match &self.device {
            Some(Device::Ledger(_)) => {
                let hmac = wallet_hmac_hex
                    .map(|hmac_hex| {
                        let hmac_bytes = hex::decode(&hmac_hex)
                            .map_err(|e| JsValue::from_str(&format!("Invalid hmac hex: {e}")))?;
                        hmac_bytes
                            .try_into()
                            .map_err(|_| JsValue::from_str("hmac must be 32 bytes (64 hex chars)"))
                    })
                    .transpose()?;
                match (policy_name, wallet_descriptor, hmac) {
                    (Some(name), Some(desc), hmac) => {
                        let wallet_policy: WalletPolicy = desc.parse().map_err(|e| {
                            JsValue::from_str(&format!("Invalid wallet descriptor: {e}"))
                        })?;
                        Some(DeviceContext::Ledger {
                            wallet_policy: LedgerWalletPolicy::new(
                                name,
                                Version::V2,
                                wallet_policy,
                            ),
                            wallet_hmac: hmac,
                        })
                    }
                    (None, None, None) => None,
                    (None, None, Some(_)) => {
                        return Err(JsValue::from_str(
                            "wallet_hmac_hex requires policy_name and wallet_descriptor",
                        ));
                    }
                    _ => {
                        return Err(JsValue::from_str(
                            "policy_name and wallet_descriptor must be provided together",
                        ));
                    }
                }
            }
            Some(Device::BitBox(_)) => wallet_descriptor
                .map(|desc| {
                    desc.parse()
                        .map(|policy| DeviceContext::BitBox { policy })
                        .map_err(|e| JsValue::from_str(&format!("Invalid wallet descriptor: {e}")))
                })
                .transpose()?,
            _ => None,
        };
        match &mut self.device {
            Some(d) => d
                .as_mut()
                .sign_tx(psbt, context)
                .await
                .map(|signed| signed.to_string()),
            None => Err(JsValue::from_str("Device not connected")),
        }
    }

    /// Signs the UTF-8 message at `path` and returns a base64-encoded signature.
    ///
    /// The 65-byte payload contains the backend's header followed by its compact
    /// ECDSA signature. Unlike the CLI, this method does not normalize the header.
    #[wasm_bindgen]
    pub async fn sign_message(&mut self, message: &str, path: &str) -> Result<String, JsValue> {
        match &mut self.device {
            Some(d) => d.as_mut().sign_message(message, path).await,
            None => Err(JsValue::from_str("Device not connected")),
        }
    }
}

/// A browser error represented as a string.
///
/// Conversion from [`JsValue`] preserves a string value or uses its debug
/// representation for non-string values.
#[derive(Debug, thiserror::Error)]
#[error("WASM error: {0}")]
pub struct WasmError(String);

impl From<JsValue> for WasmError {
    fn from(value: JsValue) -> Self {
        Self(value.as_string().unwrap_or_else(|| format!("{:?}", value)))
    }
}