Skip to main content

zinc_core/
lib.rs

1//! Zinc Core - Bitcoin + Ordinals wallet engine.
2//!
3//! `zinc-core` provides reusable wallet primitives for native Rust and WASM hosts:
4//! mnemonic handling, descriptor-backed account management, sync helpers, transaction
5//! signing, and Ordinal Shield PSBT analysis.
6//!
7//! Quick start:
8//! ```rust
9//! use zinc_core::{Network, WalletBuilder, ZincMnemonic};
10//!
11//! let mnemonic = ZincMnemonic::parse(
12//!     "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
13//! )
14//! .expect("valid mnemonic");
15//! let mut wallet = WalletBuilder::from_mnemonic(Network::Regtest, &mnemonic)
16//!     .build()
17//!     .expect("wallet");
18//! let _address = wallet.next_taproot_address().expect("address");
19//! ```
20//!
21//! Additional examples are available in `examples/`:
22//! `wallet_setup`, `sync_and_balance`, and `psbt_sign_audit`.
23
24use serde::Serialize;
25use std::future::Future;
26use wasm_bindgen::prelude::*;
27
28#[macro_use]
29mod logging;
30
31// Core modules
32pub mod builder;
33pub mod crypto;
34pub mod error;
35/// Transaction history models and wallet history helpers.
36pub mod history;
37pub mod keys;
38/// Offer envelope models and deterministic offer hashing/signature helpers.
39pub mod offer;
40/// Offer acceptance safety checks and signing plan derivation.
41pub mod offer_accept;
42/// Offer creation helpers for ord-compatible buyer offers.
43pub mod offer_create;
44/// Nostr event models and signing/verification helpers for decentralized offers.
45pub mod offer_nostr;
46/// Native Nostr relay publish/discovery transport for offer events.
47#[cfg(not(target_arch = "wasm32"))]
48pub mod offer_relay;
49/// Ordinals data models, HTTP client, and protection analysis.
50pub mod ordinals;
51
52// Re-exports for convenience
53pub use builder::{
54    Account, AddressScheme, CreatePsbtRequest, CreatePsbtTransportRequest, DiscoveryAccountPlan,
55    DiscoveryContext, Seed64, SignOptions, SyncRequestType, SyncSleeper, WalletBuilder,
56    ZincBalance, ZincPersistence, ZincSyncRequest, ZincWallet,
57};
58pub use error::{ZincError, ZincResult};
59pub use history::TxItem;
60pub use keys::{taproot_descriptors, DescriptorPair, ZincMnemonic};
61pub use offer::OfferEnvelopeV1;
62pub use offer_accept::{prepare_offer_acceptance, OfferAcceptancePlanV1};
63pub use offer_create::{CreateOfferRequest, OfferCreateResultV1};
64pub use offer_nostr::{NostrOfferEvent, OFFER_EVENT_KIND};
65#[cfg(not(target_arch = "wasm32"))]
66pub use offer_relay::{NostrRelayClient, RelayPublishResult, RelayQueryOptions};
67pub use ordinals::client::OrdClient;
68pub use ordinals::types::{Inscription, Satpoint};
69
70// Re-export bitcoin types we use
71pub use bdk_wallet::bitcoin::Network;
72use bdk_wallet::KeychainKind;
73
74// ============================================================================
75// Core Logic (Pure Rust)
76// ============================================================================
77
78#[doc(hidden)]
79/// Mnemonic material returned by wallet generation/decryption helpers.
80pub struct WalletResult {
81    /// Full normalized BIP-39 mnemonic phrase.
82    pub phrase: String,
83    /// Phrase split into individual words.
84    pub words: Vec<String>,
85}
86
87#[doc(hidden)]
88/// Generate a new mnemonic-backed wallet result for native Rust callers.
89pub fn generate_wallet_internal(word_count: u8) -> Result<WalletResult, ZincError> {
90    let mnemonic = ZincMnemonic::generate(word_count)?;
91    Ok(WalletResult {
92        phrase: mnemonic.phrase(),
93        words: mnemonic.words(),
94    })
95}
96
97#[doc(hidden)]
98/// Validate whether `phrase` is a syntactically valid BIP-39 mnemonic.
99pub fn validate_mnemonic_internal(phrase: &str) -> bool {
100    ZincMnemonic::parse(phrase).is_ok()
101}
102
103#[doc(hidden)]
104/// Derive the first external Taproot address from a mnemonic on `network`.
105pub fn derive_address_internal(phrase: &str, network: Network) -> Result<String, ZincError> {
106    let mnemonic = ZincMnemonic::parse(phrase)?;
107    let descriptors = crate::keys::taproot_descriptors(&mnemonic, network)?;
108
109    let wallet = bdk_wallet::Wallet::create(
110        descriptors.external.to_string(),
111        descriptors.internal.to_string(),
112    )
113    .network(network)
114    .create_wallet_no_persist()
115    .map_err(|e| ZincError::BdkError(e.to_string()))?;
116
117    let address = wallet.peek_address(KeychainKind::External, 0);
118    Ok(address.address.to_string())
119}
120
121#[doc(hidden)]
122/// Encrypt a mnemonic phrase with a password and return serialized JSON payload.
123pub fn encrypt_wallet_internal(mnemonic: &str, password: &str) -> Result<String, ZincError> {
124    let m = ZincMnemonic::parse(mnemonic)?;
125    let encrypted = crypto::encrypt_seed(m.phrase().as_bytes(), password)?;
126    serde_json::to_string(&encrypted).map_err(|e| ZincError::SerializationError(e.to_string()))
127}
128
129#[doc(hidden)]
130/// Decrypt an encrypted wallet JSON payload and recover mnemonic details.
131pub fn decrypt_wallet_internal(
132    encrypted_json: &str,
133    password: &str,
134) -> Result<WalletResult, ZincError> {
135    let encrypted: crypto::EncryptedWallet = serde_json::from_str(encrypted_json)
136        .map_err(|e| ZincError::SerializationError(e.to_string()))?;
137
138    let plaintext = crypto::decrypt_seed(&encrypted, password)?;
139
140    let phrase = String::from_utf8(plaintext.to_vec())
141        .map_err(|e| ZincError::SerializationError(format!("Invalid UTF-8: {}", e)))?;
142
143    let mnemonic = ZincMnemonic::parse(&phrase)?;
144
145    Ok(WalletResult {
146        phrase: mnemonic.phrase(),
147        words: mnemonic.words(),
148    })
149}
150
151// ============================================================================
152// WASM Bindings
153// ============================================================================
154
155use std::sync::Once;
156
157static INIT: Once = Once::new();
158const LOG_TARGET_WASM: &str = "zinc_core::wasm";
159
160async fn account_is_active_from_receive_scan<F, Fut>(
161    address_scan_depth: u32,
162    mut has_activity_at: F,
163) -> bool
164where
165    F: FnMut(u32) -> Fut,
166    Fut: Future<Output = bool>,
167{
168    let depth = address_scan_depth.max(1);
169    const ADDRESS_SCAN_BATCH_SIZE: u32 = 20;
170
171    let mut batch_start = 0;
172    while batch_start < depth {
173        let batch_end = (batch_start + ADDRESS_SCAN_BATCH_SIZE).min(depth);
174        let mut checks = Vec::with_capacity((batch_end - batch_start) as usize);
175        for address_index in batch_start..batch_end {
176            checks.push(has_activity_at(address_index));
177        }
178
179        let results = futures_util::future::join_all(checks).await;
180        if results.into_iter().any(|is_active| is_active) {
181            return true;
182        }
183
184        batch_start = batch_end;
185    }
186
187    false
188}
189
190/// Initialize WASM module (call once on load).
191#[wasm_bindgen(start)]
192pub fn init() {
193    zinc_log_trace!(target: LOG_TARGET_WASM, "init invoked");
194    INIT.call_once(|| {
195        // Better panic messages in the console
196        console_error_panic_hook::set_once();
197        zinc_log_info!(target: LOG_TARGET_WASM, "WASM module initialized");
198    });
199}
200
201/// Set runtime log level for zinc-core internals.
202#[wasm_bindgen]
203pub fn set_log_level(level: &str) -> Result<(), JsValue> {
204    let Some(parsed) = logging::parse_level(level) else {
205        zinc_log_warn!(
206            target: LOG_TARGET_WASM,
207            "rejected invalid log level request ({})",
208            logging::redacted_field("requested_level", level)
209        );
210        zinc_log_error!(
211            target: LOG_TARGET_WASM,
212            "invalid runtime log level request rejected"
213        );
214        return Err(JsValue::from_str(
215            "Invalid log level. Use one of: off, error, warn, info, debug, trace",
216        ));
217    };
218
219    logging::set_log_level(parsed);
220    zinc_log_info!(
221        target: LOG_TARGET_WASM,
222        "runtime log level updated to {}",
223        parsed.as_str()
224    );
225    Ok(())
226}
227
228/// Enable or disable zinc-core logging at runtime.
229#[wasm_bindgen]
230pub fn set_logging_enabled(enabled: bool) {
231    logging::set_logging_enabled(enabled);
232    zinc_log_info!(
233        target: LOG_TARGET_WASM,
234        "runtime logging {}",
235        if enabled { "enabled" } else { "disabled" }
236    );
237}
238
239/// Get current runtime log level.
240#[wasm_bindgen]
241pub fn get_log_level() -> String {
242    logging::get_log_level().as_str().to_string()
243}
244
245/// Generate a new wallet with a random mnemonic.
246#[wasm_bindgen]
247pub fn generate_wallet(word_count: u8) -> Result<JsValue, JsValue> {
248    let result =
249        generate_wallet_internal(word_count).map_err(|e| JsValue::from_str(&e.to_string()))?;
250
251    let js_result = serde_json::json!({
252        "words": result.words,
253        "phrase": result.phrase,
254    });
255
256    serde_wasm_bindgen::to_value(&js_result).map_err(|e| JsValue::from_str(&e.to_string()))
257}
258
259/// Validate a mnemonic phrase.
260#[wasm_bindgen]
261pub fn validate_mnemonic(phrase: &str) -> bool {
262    validate_mnemonic_internal(phrase)
263}
264
265/// Derive a Taproot address from a mnemonic.
266#[wasm_bindgen]
267pub fn derive_address(phrase: &str, network: &str) -> Result<String, JsValue> {
268    let network = match network {
269        "mainnet" | "bitcoin" => Network::Bitcoin,
270        "signet" => Network::Signet,
271        "testnet" => Network::Testnet,
272        "regtest" => Network::Regtest,
273        _ => return Err(JsValue::from_str("Invalid network")),
274    };
275
276    derive_address_internal(phrase, network).map_err(|e| JsValue::from_str(&e.to_string()))
277}
278
279/// Encrypt a mnemonic with a password.
280#[wasm_bindgen]
281pub fn encrypt_wallet(mnemonic: &str, password: &str) -> Result<String, JsValue> {
282    encrypt_wallet_internal(mnemonic, password).map_err(|e| JsValue::from_str(&e.to_string()))
283}
284
285#[derive(Serialize)]
286/// WASM response payload for mnemonic decryption.
287pub struct DecryptResponse {
288    /// Whether decryption succeeded.
289    pub success: bool,
290    /// Decrypted BIP-39 phrase.
291    pub phrase: String,
292    /// Decrypted phrase split into words.
293    pub words: Vec<String>,
294}
295
296/// Decrypt an encrypted wallet blob.
297#[wasm_bindgen]
298pub fn decrypt_wallet(encrypted_json: &str, password: &str) -> Result<JsValue, JsValue> {
299    zinc_log_debug!(target: LOG_TARGET_WASM,
300        "decrypt_wallet called. Encrypted length: {}, Password length: {}",
301        encrypted_json.len(),
302        password.len()
303    );
304
305    let result = match decrypt_wallet_internal(encrypted_json, password) {
306        Ok(res) => {
307            zinc_log_debug!(target: LOG_TARGET_WASM,
308                "Internal decryption success. Phrase length: {}",
309                res.phrase.len()
310            );
311            res
312        }
313        Err(e) => {
314            zinc_log_debug!(target: LOG_TARGET_WASM, "Internal decryption failed: {:?}", e);
315            return Err(JsValue::from_str(&e.to_string()));
316        }
317    };
318
319    let response = DecryptResponse {
320        success: true,
321        phrase: result.phrase,
322        words: result.words,
323    };
324
325    zinc_log_debug!(target: LOG_TARGET_WASM, "Serializing response...");
326    match serde_wasm_bindgen::to_value(&response) {
327        Ok(val) => {
328            zinc_log_debug!(target: LOG_TARGET_WASM, "Serialization success.");
329            Ok(val)
330        }
331        Err(e) => {
332            zinc_log_debug!(target: LOG_TARGET_WASM, "Serialization failed: {:?}", e);
333            Err(JsValue::from_str(&e.to_string()))
334        }
335    }
336}
337
338// ============================================================================
339// Stateful Wallet Interface
340// ============================================================================
341
342use std::cell::{Cell, RefCell};
343use std::rc::Rc;
344
345const VITALITY_MAGIC: u32 = 0x005a_11ad;
346#[cfg(target_arch = "wasm32")]
347const SYNC_STALE_ERROR: &str = "Wallet state changed during sync; stale result discarded";
348#[cfg(target_arch = "wasm32")]
349const ORD_SYNC_STALE_ERROR: &str =
350    "Wallet state changed during ordinals sync; stale result discarded";
351
352#[derive(Clone, Copy)]
353struct WalletState {
354    network: Network,
355    scheme: AddressScheme,
356    account_index: u32,
357}
358
359#[wasm_bindgen]
360/// WASM-safe stateful wallet handle wrapping the core `ZincWallet`.
361pub struct ZincWasmWallet {
362    inner: Rc<RefCell<ZincWallet>>,
363    phrase: String, // Stored for re-building inner wallet on scheme change
364    state: Cell<WalletState>,
365    vitality: u32,
366}
367
368#[wasm_bindgen]
369impl ZincWasmWallet {
370    #[wasm_bindgen(constructor)]
371    #[allow(clippy::needless_pass_by_value)]
372    /// Create a wallet from a plaintext mnemonic phrase.
373    ///
374    /// `network` accepts: `mainnet`, `bitcoin`, `testnet`, `signet`, `regtest`.
375    pub fn new(
376        network: &str,
377        phrase: &str,
378        scheme_str: Option<String>,
379        persistence_json: Option<String>,
380        account_index: Option<u32>,
381    ) -> Result<ZincWasmWallet, JsValue> {
382        let network_enum = match network {
383            "mainnet" | "bitcoin" => Network::Bitcoin,
384            "signet" => Network::Signet,
385            "testnet" => Network::Testnet,
386            "regtest" => Network::Regtest,
387            _ => return Err(JsValue::from_str("Invalid network")),
388        };
389
390        let mnemonic =
391            ZincMnemonic::parse(phrase).map_err(|e| JsValue::from_str(&e.to_string()))?;
392
393        Self::init_wallet(
394            network_enum,
395            phrase,
396            mnemonic,
397            scheme_str,
398            persistence_json,
399            account_index,
400        )
401    }
402
403    /// Initialize wallet from encrypted wallet payload (preferred for security).
404    #[wasm_bindgen]
405    pub fn new_encrypted(
406        network: &str,
407        encrypted_json: &str,
408        password: &str,
409        scheme_str: Option<String>,
410        persistence_json: Option<String>,
411        account_index: Option<u32>,
412    ) -> Result<ZincWasmWallet, JsValue> {
413        let network_enum = match network {
414            "mainnet" | "bitcoin" => Network::Bitcoin,
415            "signet" => Network::Signet,
416            "testnet" => Network::Testnet,
417            "regtest" => Network::Regtest,
418            _ => return Err(JsValue::from_str("Invalid network")),
419        };
420
421        let result = decrypt_wallet_internal(encrypted_json, password)
422            .map_err(|e| JsValue::from_str(&e.to_string()))?;
423
424        let mnemonic =
425            ZincMnemonic::parse(&result.phrase).map_err(|e| JsValue::from_str(&e.to_string()))?;
426
427        Self::init_wallet(
428            network_enum,
429            &result.phrase,
430            mnemonic,
431            scheme_str,
432            persistence_json,
433            account_index,
434        )
435    }
436
437    fn init_wallet(
438        network: Network,
439        phrase: &str,
440        mnemonic: ZincMnemonic,
441        scheme_str: Option<String>,
442        persistence_json: Option<String>,
443        account_index: Option<u32>,
444    ) -> Result<ZincWasmWallet, JsValue> {
445        // Default to Unified if not specified
446        let scheme = match scheme_str.as_deref() {
447            Some("dual") => AddressScheme::Dual,
448            _ => AddressScheme::Unified,
449        };
450
451        let active_index = account_index.unwrap_or(0);
452
453        let mut builder = WalletBuilder::from_mnemonic(network, &mnemonic);
454        builder = builder.with_scheme(scheme).with_account_index(active_index);
455
456        if let Some(json) = persistence_json {
457            builder = builder
458                .with_persistence(&json)
459                .map_err(|e| JsValue::from_str(&e))?;
460        }
461
462        let wallet = builder.build().map_err(|e| JsValue::from_str(&e))?;
463
464        Ok(ZincWasmWallet {
465            inner: Rc::new(RefCell::new(wallet)),
466            phrase: phrase.to_string(),
467            state: Cell::new(WalletState {
468                network,
469                scheme,
470                account_index: active_index,
471            }),
472            vitality: VITALITY_MAGIC,
473        })
474    }
475
476    fn check_vitality(&self) -> Result<(), JsValue> {
477        if self.vitality != VITALITY_MAGIC {
478            return Err(JsValue::from_str("Wallet handle is stale or corrupted due to context destruction. Please reload the extension."));
479        }
480        // Defensive check: ensure the Rc's strong count is sane.
481        // A count of 0 would trigger UB in Rc::clone / try_borrow.
482        let sc = Rc::strong_count(&self.inner);
483        if sc == 0 {
484            return Err(JsValue::from_str(
485                "Internal error: Rc strong count is 0 (memory corruption). Please reload the extension."
486            ));
487        }
488        Ok(())
489    }
490
491    fn state_snapshot(&self) -> WalletState {
492        self.state.get()
493    }
494
495    fn replace_wallet(
496        &self,
497        mut next_wallet: ZincWallet,
498        next_state: WalletState,
499        busy_context: &str,
500    ) -> Result<(), JsValue> {
501        match self.inner.try_borrow_mut() {
502            Ok(mut inner) => {
503                next_wallet.account_generation = inner.account_generation().wrapping_add(1);
504                *inner = next_wallet;
505                self.state.set(next_state);
506                Ok(())
507            }
508            Err(e) => Err(JsValue::from_str(&format!(
509                "Wallet busy ({}): {}",
510                busy_context, e
511            ))),
512        }
513    }
514
515    #[cfg(target_arch = "wasm32")]
516    fn generation_mismatch_error(
517        inner_rc: &Rc<RefCell<ZincWallet>>,
518        expected_generation: u64,
519        message: &str,
520    ) -> Option<JsValue> {
521        match inner_rc.try_borrow() {
522            Ok(inner) if inner.account_generation() != expected_generation => {
523                Some(JsValue::from_str(message))
524            }
525            _ => None,
526        }
527    }
528
529    #[cfg(target_arch = "wasm32")]
530    fn clear_syncing_if_generation_matches(
531        inner_rc: &Rc<RefCell<ZincWallet>>,
532        expected_generation: u64,
533    ) {
534        if let Ok(mut inner) = inner_rc.try_borrow_mut() {
535            if inner.account_generation() == expected_generation {
536                inner.is_syncing = false;
537            }
538        }
539    }
540
541    /// Export current in-memory wallet changesets as serialized JSON.
542    pub fn export_changeset(&self) -> Result<String, JsValue> {
543        self.check_vitality()?;
544        zinc_log_debug!(target: LOG_TARGET_WASM, "export_changeset called (wrapper)");
545        let res = match self.inner.try_borrow() {
546            Ok(inner) => inner
547                .export_changeset()
548                .map_err(|e| JsValue::from_str(&e))
549                .and_then(|p| {
550                    serde_json::to_string(&p).map_err(|e| JsValue::from_str(&e.to_string()))
551                }),
552            Err(e) => {
553                zinc_log_debug!(target: LOG_TARGET_WASM, "export_changeset failed to borrow: {:?}", e);
554                Err(JsValue::from_str(&format!(
555                    "Wallet busy (export_changeset): {}",
556                    e
557                )))
558            }
559        };
560        zinc_log_debug!(target: LOG_TARGET_WASM, "export_changeset finished (wrapper)");
561        res
562    }
563
564    /// Change the address scheme (Unified <-> Dual) on the fly.
565    /// This rebuilds the internal wallet using the stored phrase.
566    pub fn set_scheme(&self, scheme_str: &str) -> Result<(), JsValue> {
567        self.check_vitality()?;
568        let new_scheme = match scheme_str {
569            "dual" => AddressScheme::Dual,
570            "unified" => AddressScheme::Unified,
571            _ => return Err(JsValue::from_str("Invalid scheme")),
572        };
573
574        let state = self.state_snapshot();
575        if state.scheme == new_scheme {
576            return Ok(());
577        }
578
579        let mnemonic =
580            ZincMnemonic::parse(&self.phrase).map_err(|e| JsValue::from_str(&e.to_string()))?;
581
582        let mut builder = WalletBuilder::from_mnemonic(state.network, &mnemonic);
583        builder = builder
584            .with_scheme(new_scheme)
585            .with_account_index(state.account_index);
586
587        let next_wallet = builder.build().map_err(|e| JsValue::from_str(&e))?;
588        self.replace_wallet(
589            next_wallet,
590            WalletState {
591                scheme: new_scheme,
592                ..state
593            },
594            "set_scheme",
595        )
596    }
597
598    /// Switch the active account index.
599    /// This rebuilds the internal wallet logic for the new account.
600    /// Note: Persistence is NOT carried over automatically for clear separation.
601    pub fn set_active_account(&self, account_index: u32) -> Result<(), JsValue> {
602        self.check_vitality()?;
603        let state = self.state_snapshot();
604        if state.account_index == account_index {
605            return Ok(());
606        }
607
608        let mnemonic =
609            ZincMnemonic::parse(&self.phrase).map_err(|e| JsValue::from_str(&e.to_string()))?;
610
611        let mut builder = WalletBuilder::from_mnemonic(state.network, &mnemonic);
612        builder = builder
613            .with_scheme(state.scheme)
614            .with_account_index(account_index);
615
616        let next_wallet = builder.build().map_err(|e| JsValue::from_str(&e))?;
617        self.replace_wallet(
618            next_wallet,
619            WalletState {
620                account_index,
621                ..state
622            },
623            "set_active_account",
624        )
625    }
626
627    /// Change the network on the fly.
628    pub fn set_network(&self, network_str: &str) -> Result<(), JsValue> {
629        self.check_vitality()?;
630        let new_network = match network_str {
631            "mainnet" => Network::Bitcoin,
632            "testnet" => Network::Testnet,
633            "signet" => Network::Signet,
634            "regtest" => Network::Regtest,
635            _ => return Err(JsValue::from_str("Invalid network")),
636        };
637
638        let state = self.state_snapshot();
639        if state.network == new_network {
640            return Ok(());
641        }
642
643        let mnemonic =
644            ZincMnemonic::parse(&self.phrase).map_err(|e| JsValue::from_str(&e.to_string()))?;
645
646        let mut builder = WalletBuilder::from_mnemonic(new_network, &mnemonic);
647        builder = builder
648            .with_scheme(state.scheme)
649            .with_account_index(state.account_index);
650
651        let next_wallet = builder.build().map_err(|e| JsValue::from_str(&e))?;
652        self.replace_wallet(
653            next_wallet,
654            WalletState {
655                network: new_network,
656                ..state
657            },
658            "set_network",
659        )
660    }
661
662    #[wasm_bindgen(js_name = get_accounts)]
663    /// Enumerate account previews from index `0..count` for the active seed.
664    pub fn get_accounts(&self, count: u32) -> Result<JsValue, JsValue> {
665        self.check_vitality()?;
666
667        // Optimize: parse mnemonic and derive seed only once
668        let mnemonic = crate::keys::ZincMnemonic::parse(&self.phrase)
669            .map_err(|e| JsValue::from_str(&e.to_string()))?;
670        let state = self.state_snapshot();
671        let network = state.network;
672        let scheme = state.scheme;
673
674        let mut accounts = Vec::new();
675        for i in 0..count {
676            let mut builder = WalletBuilder::from_mnemonic(network, &mnemonic);
677            builder = builder.with_scheme(scheme).with_account_index(i);
678
679            // Build temporary wallet (no persistence)
680            let zwallet = builder.build().map_err(|e| JsValue::from_str(&e))?;
681
682            // Use peek_address for speed (no revealing/saving in memory)
683            let vault_addr = zwallet
684                .vault_wallet
685                .peek_address(KeychainKind::External, 0)
686                .address;
687
688            let vault_pubkey = zwallet
689                .get_taproot_public_key(0)
690                .unwrap_or_else(|_| "".to_string());
691
692            let (payment_addr, payment_pubkey) = if scheme == AddressScheme::Dual {
693                (
694                    Some(
695                        zwallet
696                            .payment_wallet
697                            .as_ref()
698                            .ok_or_else(|| {
699                                JsValue::from_str("Payment wallet missing in dual mode")
700                            })?
701                            .peek_address(KeychainKind::External, 0)
702                            .address
703                            .to_string(),
704                    ),
705                    Some(
706                        zwallet
707                            .get_payment_public_key(0)
708                            .unwrap_or_else(|_| "".to_string()),
709                    ),
710                )
711            } else {
712                (None, None)
713            };
714
715            accounts.push(serde_json::json!({
716                "index": i,
717                "label": format!("Account {}", i),
718                "taprootAddress": vault_addr.to_string(),
719                "taprootPublicKey": vault_pubkey,
720                "paymentAddress": payment_addr,
721                "paymentPublicKey": payment_pubkey,
722                // Backward-compatible aliases for older clients.
723                "vaultAddress": vault_addr.to_string(),
724                "vaultPublicKey": vault_pubkey,
725            }));
726        }
727
728        serde_wasm_bindgen::to_value(&accounts).map_err(|e| JsValue::from_str(&e.to_string()))
729    }
730
731    /// Return cached inscription list currently loaded in wallet state.
732    pub fn get_inscriptions(&self) -> Result<JsValue, JsValue> {
733        self.check_vitality()?;
734        match self.inner.try_borrow() {
735            Ok(inner) => serde_wasm_bindgen::to_value(&inner.inscriptions).map_err(|e| {
736                JsValue::from_str(&format!("Failed to serialize inscriptions: {}", e))
737            }),
738            Err(e) => Err(JsValue::from_str(&format!(
739                "Wallet busy (get_inscriptions): {}",
740                e
741            ))),
742        }
743    }
744
745    /// Return total, spendable, display-spendable, and inscribed balances.
746    pub fn get_balance(&self) -> Result<JsValue, JsValue> {
747        self.check_vitality()?;
748        match self.inner.try_borrow() {
749            Ok(inner) => {
750                let balance = inner.get_balance();
751                let json = serde_json::json!({
752                    "total": {
753                        "confirmed": balance.total.confirmed.to_sat(),
754                        "trusted_pending": balance.total.trusted_pending.to_sat(),
755                        "untrusted_pending": balance.total.untrusted_pending.to_sat(),
756                        "immature": balance.total.immature.to_sat(),
757                    },
758                    "spendable": {
759                        "confirmed": balance.spendable.confirmed.to_sat(),
760                        "trusted_pending": balance.spendable.trusted_pending.to_sat(),
761                        "untrusted_pending": balance.spendable.untrusted_pending.to_sat(),
762                        "immature": balance.spendable.immature.to_sat(),
763                    },
764                    "display_spendable": {
765                        "confirmed": balance.display_spendable.confirmed.to_sat(),
766                        "trusted_pending": balance.display_spendable.trusted_pending.to_sat(),
767                        "untrusted_pending": balance.display_spendable.untrusted_pending.to_sat(),
768                        "immature": balance.display_spendable.immature.to_sat(),
769                    },
770                    "inscribed": balance.inscribed
771                });
772
773                // Use explicit serializer to ensure maps are converted to JS objects, not Map class
774                let serializer =
775                    serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true);
776                json.serialize(&serializer)
777                    .map_err(|e| JsValue::from_str(&e.to_string()))
778            }
779            Err(e) => Err(JsValue::from_str(&format!(
780                "Wallet busy (get_balance): {}",
781                e
782            ))),
783        }
784    }
785
786    /// Return recent wallet transactions ordered by pending-first then newest-first.
787    pub fn get_transactions(&self, limit: usize) -> Result<JsValue, JsValue> {
788        self.check_vitality()?;
789        match self.inner.try_borrow() {
790            Ok(inner) => {
791                let txs = inner.get_transactions(limit);
792                serde_wasm_bindgen::to_value(&txs)
793                    .map_err(|e| JsValue::from(format!("Failed to serialize transactions: {e}")))
794            }
795            Err(e) => Err(JsValue::from_str(&format!(
796                "Wallet busy (get_transactions): {}",
797                e
798            ))),
799        }
800    }
801
802    /// Return first receive addresses/public keys for taproot/payment roles.
803    ///
804    /// In unified mode, `payment*` mirrors the same taproot branch.
805    pub fn get_addresses(&self) -> Result<JsValue, JsValue> {
806        self.check_vitality()?;
807        match self.inner.try_borrow() {
808            Ok(inner) => {
809                let account_idx = inner.account_index;
810                let vault_addr = inner
811                    .vault_wallet
812                    .peek_address(KeychainKind::External, 0)
813                    .address;
814                let vault_pubkey = inner
815                    .get_taproot_public_key(0)
816                    .unwrap_or_else(|_| "".to_string());
817
818                zinc_log_debug!(
819                    target: LOG_TARGET_WASM,
820                    "get_addresses - account: {}, taproot: {}",
821                    account_idx,
822                    vault_addr
823                );
824
825                // We use inner.is_unified() so address behavior follows active inner wallet state.
826                let (payment_addr, payment_pubkey) = if inner.is_unified() {
827                    (Some(vault_addr.to_string()), Some(vault_pubkey.clone()))
828                } else {
829                    let addr = inner
830                        .payment_wallet
831                        .as_ref()
832                        .ok_or_else(|| JsValue::from_str("Payment wallet missing in dual mode"))?
833                        .peek_address(KeychainKind::External, 0)
834                        .address;
835                    let pubkey = inner
836                        .get_payment_public_key(0)
837                        .unwrap_or_else(|_| "".to_string());
838                    zinc_log_debug!(target: LOG_TARGET_WASM, "get_addresses - payment: {}", addr);
839                    (Some(addr.to_string()), Some(pubkey))
840                };
841
842                let json = serde_json::json!({
843                    "account_index": account_idx,
844                    "taproot": vault_addr.to_string(),
845                    "taprootPublicKey": vault_pubkey,
846                    "payment": payment_addr,
847                    "paymentPublicKey": payment_pubkey,
848                    // Backward-compatible aliases for older clients.
849                    "vault": vault_addr.to_string(),
850                    "vaultPublicKey": vault_pubkey
851                });
852                serde_wasm_bindgen::to_value(&json).map_err(|e| JsValue::from(e.to_string()))
853            }
854            Err(e) => Err(JsValue::from_str(&format!(
855                "Wallet busy (get_addresses): {}",
856                e
857            ))),
858        }
859    }
860
861    #[cfg(target_arch = "wasm32")]
862    #[wasm_bindgen(js_name = sync)]
863    pub fn sync(&self, esplora_url: String) -> Result<js_sys::Promise, JsValue> {
864        self.check_vitality()?;
865        use crate::builder::{SyncRequestType, SyncSleeper};
866        use bdk_esplora::EsploraAsyncExt;
867
868        let inner_rc = self.inner.clone();
869
870        Ok(wasm_bindgen_futures::future_to_promise(async move {
871            zinc_log_debug!(
872                target: LOG_TARGET_WASM,
873                "sync start ({})",
874                logging::redacted_field("esplora_url", &esplora_url)
875            );
876
877            // 1. Prepare Request (lock briefly)
878            let (sync_req, sync_generation) = {
879                match inner_rc.try_borrow_mut() {
880                    Ok(mut inner) => {
881                        if inner.is_syncing {
882                            zinc_log_debug!(target: LOG_TARGET_WASM, "Sync already in progress, skipping.");
883                            return Err(JsValue::from_str("Wallet Busy: Sync already in progress"));
884                        }
885                        inner.is_syncing = true;
886                        zinc_log_debug!(target: LOG_TARGET_WASM, "borrow successful, preparing requests");
887                        (inner.prepare_requests(), inner.account_generation())
888                    }
889                    Err(e) => {
890                        zinc_log_debug!(target: LOG_TARGET_WASM, "sync: FAILED TO BORROW INNER: {:?}", e);
891                        return Err(JsValue::from_str(&format!(
892                            "Failed to borrow wallet inner state: {}",
893                            e
894                        )));
895                    }
896                }
897            };
898
899            let client = match esplora_client::Builder::new(&esplora_url)
900                .build_async_with_sleeper::<SyncSleeper>()
901            {
902                Ok(c) => c,
903                Err(e) => {
904                    zinc_log_error!(target: LOG_TARGET_WASM, "failed to create esplora client");
905                    zinc_log_debug!(
906                        target: LOG_TARGET_WASM,
907                        "failed to create esplora client: {:?}",
908                        e
909                    );
910                    ZincWasmWallet::clear_syncing_if_generation_matches(&inner_rc, sync_generation);
911                    if let Some(stale) = ZincWasmWallet::generation_mismatch_error(
912                        &inner_rc,
913                        sync_generation,
914                        SYNC_STALE_ERROR,
915                    ) {
916                        return Err(stale);
917                    }
918                    return Err(JsValue::from(format!("{:?}", e)));
919                }
920            };
921
922            // 2. Fetch (NO LOCK HELD)
923            let vault_update_res: Result<bdk_wallet::Update, JsValue> = match sync_req.taproot {
924                SyncRequestType::Full(req) => {
925                    zinc_log_info!(target: LOG_TARGET_WASM, "starting taproot full scan");
926                    client
927                        .full_scan(req, 20, 1)
928                        .await
929                        .map(|u| u.into())
930                        .map_err(|e| {
931                            zinc_log_debug!(target: LOG_TARGET_WASM, "Vault full scan failed: {:?}", e);
932                            JsValue::from(e.to_string())
933                        })
934                }
935                SyncRequestType::Incremental(req) => {
936                    zinc_log_info!(target: LOG_TARGET_WASM, "starting taproot incremental sync");
937                    client.sync(req, 1).await.map(|u| u.into()).map_err(|e| {
938                        zinc_log_debug!(target: LOG_TARGET_WASM, "Vault sync failed: {:?}", e);
939                        JsValue::from(e.to_string())
940                    })
941                }
942            };
943
944            let vault_update = match vault_update_res {
945                Ok(u) => u,
946                Err(e) => {
947                    ZincWasmWallet::clear_syncing_if_generation_matches(&inner_rc, sync_generation);
948                    if let Some(stale) = ZincWasmWallet::generation_mismatch_error(
949                        &inner_rc,
950                        sync_generation,
951                        SYNC_STALE_ERROR,
952                    ) {
953                        return Err(stale);
954                    }
955                    return Err(e);
956                }
957            };
958
959            let payment_update: Option<bdk_wallet::Update> = if let Some(req_type) =
960                sync_req.payment
961            {
962                let update_res: Result<bdk_wallet::Update, JsValue> = match req_type {
963                    SyncRequestType::Full(req) => {
964                        zinc_log_info!(target: LOG_TARGET_WASM, "starting payment full scan");
965                        client
966                                .full_scan(req, 20, 1)
967                                .await
968                                .map(|u| u.into())
969                                .map_err(|e| {
970                                    zinc_log_debug!(target: LOG_TARGET_WASM, "Payment full scan failed: {:?}", e);
971                                    JsValue::from(e.to_string())
972                                })
973                    }
974                    SyncRequestType::Incremental(req) => {
975                        zinc_log_info!(
976                            target: LOG_TARGET_WASM,
977                            "starting payment incremental sync"
978                        );
979                        client.sync(req, 1).await.map(|u| u.into()).map_err(|e| {
980                                zinc_log_debug!(target: LOG_TARGET_WASM, "Payment sync failed: {:?}", e);
981                                JsValue::from(e.to_string())
982                            })
983                    }
984                };
985
986                match update_res {
987                    Ok(u) => Some(u),
988                    Err(e) => {
989                        ZincWasmWallet::clear_syncing_if_generation_matches(
990                            &inner_rc,
991                            sync_generation,
992                        );
993                        if let Some(stale) = ZincWasmWallet::generation_mismatch_error(
994                            &inner_rc,
995                            sync_generation,
996                            SYNC_STALE_ERROR,
997                        ) {
998                            return Err(stale);
999                        }
1000                        return Err(e);
1001                    }
1002                }
1003            } else {
1004                None
1005            };
1006            zinc_log_debug!(target: LOG_TARGET_WASM, "sync: chain client returned");
1007
1008            // 3. Apply (lock briefly)
1009            let events = {
1010                match inner_rc.try_borrow_mut() {
1011                    Ok(mut inner) => {
1012                        if inner.account_generation() != sync_generation {
1013                            return Err(JsValue::from_str(SYNC_STALE_ERROR));
1014                        }
1015                        zinc_log_debug!(target: LOG_TARGET_WASM, "sync: applying updates");
1016                        let res = inner
1017                            .apply_sync(vault_update, payment_update)
1018                            .map_err(|e| {
1019                                inner.is_syncing = false;
1020                                zinc_log_error!(target: LOG_TARGET_WASM, "failed to apply sync");
1021                                zinc_log_debug!(
1022                                    target: LOG_TARGET_WASM,
1023                                    "failed to apply sync update: {}",
1024                                    e
1025                                );
1026                                JsValue::from(e)
1027                            })?;
1028                        inner.is_syncing = false;
1029                        zinc_log_debug!(target: LOG_TARGET_WASM, "sync: updates applied");
1030                        res
1031                    }
1032                    Err(e) => {
1033                        zinc_log_debug!(target: LOG_TARGET_WASM, "FAILED TO BORROW MUT INNER: {:?}", e);
1034                        return Err(JsValue::from_str(&format!(
1035                            "Failed to borrow wallet inner state (mut): {}",
1036                            e
1037                        )));
1038                    }
1039                }
1040            };
1041            zinc_log_debug!(target: LOG_TARGET_WASM, "sync: finished. events: {:?}", events);
1042
1043            serde_wasm_bindgen::to_value(&events).map_err(|e| JsValue::from(e.to_string()))
1044        }))
1045    }
1046
1047    #[cfg(target_arch = "wasm32")]
1048    #[wasm_bindgen(js_name = discoverAccounts)]
1049    pub fn discover_accounts(
1050        &self,
1051        esplora_url: String,
1052        account_gap_limit: u32,
1053        address_scan_depth: Option<u32>,
1054        timeout_ms: Option<u32>,
1055    ) -> Result<js_sys::Promise, JsValue> {
1056        self.check_vitality()?;
1057
1058        let mnemonic = crate::keys::ZincMnemonic::parse(&self.phrase)
1059            .map_err(|e| JsValue::from_str(&e.to_string()))?;
1060        let seed = crate::builder::Seed64::from_array(*mnemonic.to_seed(""));
1061        let state = self.state_snapshot();
1062        let network = state.network;
1063        let scheme = state.scheme;
1064        let account_gap_limit = account_gap_limit.max(1);
1065        let address_scan_depth = address_scan_depth.unwrap_or(20).max(1);
1066        let timeout_ms = timeout_ms.unwrap_or(120_000).max(1);
1067
1068        Ok(wasm_bindgen_futures::future_to_promise(async move {
1069            zinc_log_debug!(
1070                target: LOG_TARGET_WASM,
1071                "discover_accounts start ({}, account_gap_limit={}, address_scan_depth={}, timeout_ms={})",
1072                logging::redacted_field("esplora_url", &esplora_url),
1073                account_gap_limit,
1074                address_scan_depth,
1075                timeout_ms
1076            );
1077
1078            let client = reqwest::Client::new();
1079            let mut max_active_index: i32 = -1;
1080            let mut current_gap = 0;
1081            let mut account_index: u32 = 0;
1082            let start_ms = js_sys::Date::now();
1083            let deadline_ms = start_ms + f64::from(timeout_ms);
1084
1085            loop {
1086                if js_sys::Date::now() >= deadline_ms {
1087                    zinc_log_warn!(
1088                        target: LOG_TARGET_WASM,
1089                        "discover_accounts reached timeout budget after {}ms (best_so_far_max_active={})",
1090                        timeout_ms,
1091                        max_active_index
1092                    );
1093                    break;
1094                }
1095
1096                if current_gap >= account_gap_limit {
1097                    break;
1098                }
1099
1100                let mut builder = WalletBuilder::from_seed(network, seed);
1101                builder = builder
1102                    .with_scheme(scheme)
1103                    .with_account_index(account_index);
1104
1105                let zwallet = builder.build().map_err(|e| JsValue::from_str(&e))?;
1106                let timed_out = std::cell::Cell::new(false);
1107                const ADDRESS_REQUEST_TIMEOUT_MS: u32 = 2_000;
1108
1109                let check_activity = |addr_str: String| {
1110                    let client = client.clone();
1111                    let url = format!("{}/address/{}", esplora_url, addr_str);
1112                    async move {
1113                        let request = async {
1114                            if let Ok(resp) = client.get(&url).send().await {
1115                                if let Ok(json) = resp.json::<serde_json::Value>().await {
1116                                    let chain_txs =
1117                                        json["chain_stats"]["tx_count"].as_u64().unwrap_or(0);
1118                                    let mempool_txs =
1119                                        json["mempool_stats"]["tx_count"].as_u64().unwrap_or(0);
1120                                    return chain_txs > 0 || mempool_txs > 0;
1121                                }
1122                            }
1123                            false
1124                        };
1125                        let timeout = gloo_timers::future::TimeoutFuture::new(ADDRESS_REQUEST_TIMEOUT_MS);
1126                        futures_util::pin_mut!(request);
1127                        futures_util::pin_mut!(timeout);
1128
1129                        match futures_util::future::select(request, timeout).await {
1130                            futures_util::future::Either::Left((value, _)) => value,
1131                            futures_util::future::Either::Right((_timed_out, _)) => false,
1132                        }
1133                    }
1134                };
1135
1136                // Scan each account's receive chain deeply enough to catch funds parked
1137                // on later derived addresses during recovery.
1138                let has_activity =
1139                    account_is_active_from_receive_scan(address_scan_depth, |address_index| {
1140                        let vault_addr = zwallet
1141                            .vault_wallet
1142                            .peek_address(KeychainKind::External, address_index)
1143                            .address
1144                            .to_string();
1145
1146                        let payment_addr = if scheme == AddressScheme::Dual {
1147                            zwallet.payment_wallet.as_ref().map(|wallet| {
1148                                wallet
1149                                    .peek_address(KeychainKind::External, address_index)
1150                                    .address
1151                                    .to_string()
1152                            })
1153                        } else {
1154                            None
1155                        };
1156
1157                        async {
1158                            if js_sys::Date::now() >= deadline_ms {
1159                                timed_out.set(true);
1160                                return false;
1161                            }
1162                            if check_activity(vault_addr).await {
1163                                return true;
1164                            }
1165                            if let Some(payment_addr) = payment_addr {
1166                                return check_activity(payment_addr).await;
1167                            }
1168                            false
1169                        }
1170                    })
1171                    .await;
1172
1173                if timed_out.get() {
1174                    zinc_log_warn!(
1175                        target: LOG_TARGET_WASM,
1176                        "discover_accounts stopped mid-account scan due to timeout budget (account_index={})",
1177                        account_index
1178                    );
1179                    break;
1180                }
1181
1182                if has_activity {
1183                    max_active_index = account_index as i32;
1184                    current_gap = 0;
1185                } else {
1186                    current_gap += 1;
1187                }
1188
1189                account_index += 1;
1190            }
1191
1192            let discovered_count = (max_active_index + 1) as u32;
1193            let final_count = if discovered_count > 0 {
1194                discovered_count
1195            } else {
1196                1
1197            }; // Always show at least 1 (also on timeout)
1198
1199            zinc_log_debug!(target: LOG_TARGET_WASM,
1200                "discover_accounts finished. Found max active = {}, returning discovery count {}",
1201                max_active_index,
1202                final_count
1203            );
1204
1205            Ok(JsValue::from(final_count))
1206        }))
1207    }
1208
1209    #[wasm_bindgen(js_name = loadInscriptions)]
1210    /// Replace wallet inscription cache from a JS value of `Inscription[]`.
1211    ///
1212    /// Security: this is treated as unverified metadata cache only. Call
1213    /// `syncOrdinals` before spend flows that require verified protection state.
1214    pub fn load_inscriptions(&self, val: JsValue) -> Result<u32, JsValue> {
1215        self.check_vitality()?;
1216        zinc_log_debug!(target: LOG_TARGET_WASM, "load_inscriptions called with JsValue");
1217
1218        let inscriptions: Vec<crate::ordinals::types::Inscription> =
1219            serde_wasm_bindgen::from_value(val).map_err(|e| {
1220                JsValue::from_str(&format!("Failed to parse inscriptions from JsValue: {}", e))
1221            })?;
1222
1223        zinc_log_debug!(target: LOG_TARGET_WASM,
1224            "Parsed {} inscriptions from JsValue. Updating wallet state...",
1225            inscriptions.len()
1226        );
1227
1228        match self.inner.try_borrow_mut() {
1229            Ok(mut inner) => {
1230                let count = inner.apply_unverified_inscriptions_cache(inscriptions);
1231                zinc_log_debug!(target: LOG_TARGET_WASM, "Inscriptions applied. New count: {}", count);
1232                Ok(count as u32)
1233            }
1234            Err(e) => {
1235                zinc_log_debug!(target: LOG_TARGET_WASM, "load_inscriptions FAILED to borrow mutable: {}", e);
1236                Err(JsValue::from_str(&format!(
1237                    "Wallet busy (load_inscriptions): {}",
1238                    e
1239                )))
1240            }
1241        }
1242    }
1243
1244    #[cfg(target_arch = "wasm32")]
1245    #[wasm_bindgen(js_name = syncOrdinals)]
1246    pub fn sync_ordinals(&self, ord_url: String) -> Result<js_sys::Promise, JsValue> {
1247        self.check_vitality()?;
1248        let inner_rc = self.inner.clone();
1249
1250        Ok(wasm_bindgen_futures::future_to_promise(async move {
1251            zinc_log_debug!(target: LOG_TARGET_WASM, "sync_ordinals start");
1252            // 1. Collect info needed for sync (Borrow Read)
1253            let (addresses, wallet_height, sync_generation) = {
1254                match inner_rc.try_borrow_mut() {
1255                    Ok(mut inner) => {
1256                        if inner.is_syncing {
1257                            zinc_log_debug!(target: LOG_TARGET_WASM, "Ord sync skipped: Wallet is busy syncing.");
1258                            return Err(JsValue::from_str(
1259                                "Wallet Busy: Operation already in progress",
1260                            ));
1261                        }
1262                        inner.is_syncing = true;
1263                        zinc_log_debug!(target: LOG_TARGET_WASM, "sync_ordinals: collecting active addresses...");
1264                        let addrs = inner.collect_active_addresses();
1265                        zinc_log_debug!(target: LOG_TARGET_WASM, "sync_ordinals: collected {} addresses", addrs.len());
1266                        for a in &addrs {
1267                            zinc_log_debug!(
1268                                target: LOG_TARGET_WASM,
1269                                "sync_ordinals address queued: {}",
1270                                a
1271                            );
1272                        }
1273                        let height = inner.vault_wallet.local_chain().tip().height();
1274                        (addrs, height, inner.account_generation())
1275                    }
1276                    Err(e) => {
1277                        zinc_log_debug!(target: LOG_TARGET_WASM, "sync_ordinals: FAILED TO BORROW INNER: {:?}", e);
1278                        return Err(JsValue::from_str(&format!("Failed to borrow: {}", e)));
1279                    }
1280                }
1281            };
1282
1283            // 2. Perform Network IO (NO Borrow Held)
1284            let client = crate::ordinals::OrdClient::new(ord_url.to_string());
1285
1286            // 2a. Check Lag
1287            let ord_height = match client.get_indexing_height().await {
1288                Ok(h) => h,
1289                Err(e) => {
1290                    zinc_log_debug!(target: LOG_TARGET_WASM, "Failed to get ord height: {:?}", e);
1291                    ZincWasmWallet::clear_syncing_if_generation_matches(&inner_rc, sync_generation);
1292                    if let Some(stale) = ZincWasmWallet::generation_mismatch_error(
1293                        &inner_rc,
1294                        sync_generation,
1295                        ORD_SYNC_STALE_ERROR,
1296                    ) {
1297                        return Err(stale);
1298                    }
1299                    return Err(JsValue::from_str(&e.to_string()));
1300                }
1301            };
1302
1303            if ord_height < wallet_height.saturating_sub(1) {
1304                // We need to set verified=false safely
1305                zinc_log_debug!(target: LOG_TARGET_WASM, "sync_ordinals: Ord lagging, setting verified=false");
1306                match inner_rc.try_borrow_mut() {
1307                    Ok(mut inner) => {
1308                        if inner.account_generation() != sync_generation {
1309                            return Err(JsValue::from_str(ORD_SYNC_STALE_ERROR));
1310                        }
1311                        inner.ordinals_verified = false;
1312                        inner.is_syncing = false;
1313                    }
1314                    Err(e) => {
1315                        zinc_log_debug!(target: LOG_TARGET_WASM,
1316                            "sync_ordinals: Failed to borrow mut for lag update: {}",
1317                            e
1318                        );
1319                    }
1320                }
1321                return Err(JsValue::from_str(&format!(
1322                    "Ord Indexer is lagging! Ord: {}, Wallet: {}. Safety lock engaged.",
1323                    ord_height, wallet_height
1324                )));
1325            }
1326
1327            // 2b. Fetch artifact metadata
1328            zinc_log_debug!(target: LOG_TARGET_WASM, "sync_ordinals: fetching inscriptions");
1329            let mut all_inscriptions = Vec::new();
1330            let mut protected_outpoints = std::collections::HashSet::new();
1331            for addr_str in addresses {
1332                match client.get_inscriptions(&addr_str).await {
1333                    Ok(list) => {
1334                        zinc_log_debug!(target: LOG_TARGET_WASM,
1335                            "sync_ordinals: found {} inscriptions for {}",
1336                            list.len(),
1337                            addr_str
1338                        );
1339                        all_inscriptions.extend(list);
1340                    }
1341                    Err(e) => {
1342                        zinc_log_debug!(target: LOG_TARGET_WASM, "Failed to fetch inscriptions for {}: {}", addr_str, e);
1343                        ZincWasmWallet::clear_syncing_if_generation_matches(
1344                            &inner_rc,
1345                            sync_generation,
1346                        );
1347                        if let Some(stale) = ZincWasmWallet::generation_mismatch_error(
1348                            &inner_rc,
1349                            sync_generation,
1350                            ORD_SYNC_STALE_ERROR,
1351                        ) {
1352                            return Err(stale);
1353                        }
1354                        return Err(JsValue::from_str(&format!(
1355                            "Failed to fetch for {}: {}",
1356                            addr_str, e
1357                        )));
1358                    }
1359                }
1360
1361                match client.get_protected_outpoints(&addr_str).await {
1362                    Ok(outpoints) => {
1363                        zinc_log_debug!(target: LOG_TARGET_WASM,
1364                            "sync_ordinals: found {} protected outputs for {}",
1365                            outpoints.len(),
1366                            addr_str
1367                        );
1368                        protected_outpoints.extend(outpoints);
1369                    }
1370                    Err(e) => {
1371                        zinc_log_debug!(target: LOG_TARGET_WASM,
1372                            "Failed to fetch protected outputs for {}: {}",
1373                            addr_str,
1374                            e
1375                        );
1376                        match inner_rc.try_borrow_mut() {
1377                            Ok(mut inner) => {
1378                                if inner.account_generation() != sync_generation {
1379                                    return Err(JsValue::from_str(ORD_SYNC_STALE_ERROR));
1380                                }
1381                                inner.ordinals_verified = false;
1382                                inner.is_syncing = false;
1383                            }
1384                            Err(_) => {}
1385                        }
1386                        return Err(JsValue::from_str(&format!(
1387                            "Failed to fetch protected outputs for {}: {}",
1388                            addr_str, e
1389                        )));
1390                    }
1391                }
1392            }
1393            zinc_log_debug!(target: LOG_TARGET_WASM,
1394                "sync_ordinals: total inscriptions found: {}",
1395                all_inscriptions.len()
1396            );
1397
1398            // 3. Apply Update (Borrow Mut)
1399            zinc_log_debug!(target: LOG_TARGET_WASM, "sync_ordinals: applying update (borrow mut)");
1400            let count = {
1401                match inner_rc.try_borrow_mut() {
1402                    Ok(mut inner) => {
1403                        if inner.account_generation() != sync_generation {
1404                            return Err(JsValue::from_str(ORD_SYNC_STALE_ERROR));
1405                        }
1406                        let c = inner
1407                            .apply_verified_ordinals_update(all_inscriptions, protected_outpoints);
1408                        inner.is_syncing = false; // FINISHED
1409                        c
1410                    }
1411                    Err(e) => {
1412                        zinc_log_debug!(target: LOG_TARGET_WASM, "sync_ordinals: FAILED TO BORROW MUT: {:?}", e);
1413                        return Err(JsValue::from_str(&format!("Failed to borrow mut: {}", e)));
1414                    }
1415                }
1416            };
1417
1418            Ok(JsValue::from(count as u32))
1419        }))
1420    }
1421    // ========================================================================
1422    // Send Flow
1423    // ========================================================================
1424
1425    fn create_psbt_with_transport(
1426        &self,
1427        transport: crate::builder::CreatePsbtTransportRequest,
1428        busy_label: &str,
1429    ) -> Result<String, JsValue> {
1430        let request = crate::builder::CreatePsbtRequest::try_from(transport)
1431            .map_err(|e| JsValue::from_str(&e.to_string()))?;
1432
1433        match self.inner.try_borrow_mut() {
1434            Ok(mut inner) => inner
1435                .create_psbt_base64(&request)
1436                .map_err(|e| JsValue::from_str(&e.to_string())),
1437            Err(e) => Err(JsValue::from_str(&format!(
1438                "Wallet busy ({}): {}",
1439                busy_label, e
1440            ))),
1441        }
1442    }
1443
1444    /// Create an unsigned PSBT for sending BTC from an object request.
1445    ///
1446    /// Request shape:
1447    /// - `recipient: string`
1448    /// - `amountSats: number`
1449    /// - `feeRateSatVb: number`
1450    #[wasm_bindgen(js_name = createPsbt)]
1451    pub fn create_psbt_request(&self, request: JsValue) -> Result<String, JsValue> {
1452        self.check_vitality()?;
1453
1454        let transport: crate::builder::CreatePsbtTransportRequest =
1455            serde_wasm_bindgen::from_value(request)
1456                .map_err(|e| JsValue::from_str(&format!("Invalid request: {e}")))?;
1457
1458        self.create_psbt_with_transport(transport, "createPsbt")
1459    }
1460
1461    /// Create an unsigned PSBT for sending BTC from positional args.
1462    ///
1463    /// Deprecated migration wrapper for consumers that haven't moved to
1464    /// `createPsbt(request)` yet.
1465    #[doc(hidden)]
1466    pub fn create_psbt(
1467        &self,
1468        recipient: &str,
1469        amount_sats: u64,
1470        fee_rate_sat_vb: u64,
1471    ) -> Result<String, JsValue> {
1472        self.check_vitality()?;
1473        self.create_psbt_with_transport(
1474            crate::builder::CreatePsbtTransportRequest {
1475                recipient: recipient.to_string(),
1476                amount_sats,
1477                fee_rate_sat_vb,
1478            },
1479            "create_psbt",
1480        )
1481    }
1482
1483    /// Sign a PSBT using the wallet's internal keys.
1484    /// Returns the signed PSBT as a base64-encoded string.
1485    #[wasm_bindgen(js_name = signPsbt)]
1486    pub fn sign_psbt(&self, psbt_base64: &str, options: JsValue) -> Result<String, JsValue> {
1487        self.check_vitality()?;
1488
1489        let sign_opts: Option<crate::builder::SignOptions> =
1490            if options.is_null() || options.is_undefined() {
1491                None
1492            } else {
1493                match serde_wasm_bindgen::from_value(options) {
1494                    Ok(opts) => Some(opts),
1495                    Err(e) => return Err(JsValue::from_str(&format!("Invalid options: {}", e))),
1496                }
1497            };
1498
1499        match self.inner.try_borrow_mut() {
1500            Ok(mut inner) => inner
1501                .sign_psbt(psbt_base64, sign_opts)
1502                .map_err(JsValue::from),
1503            Err(e) => Err(JsValue::from_str(&format!(
1504                "Wallet busy (sign_psbt): {}",
1505                e
1506            ))),
1507        }
1508    }
1509
1510    /// Analyzes a PSBT for Ordinal Shield protection.
1511    /// Returns a JSON string containing the AnalysisResult.
1512    #[wasm_bindgen(js_name = analyzePsbt)]
1513    pub fn analyze_psbt(&self, psbt_base64: &str) -> Result<String, JsValue> {
1514        self.check_vitality()?;
1515        match self.inner.try_borrow() {
1516            Ok(inner) => inner.analyze_psbt(psbt_base64).map_err(JsValue::from),
1517            Err(e) => Err(JsValue::from_str(&format!(
1518                "Wallet busy (analyze_psbt): {}",
1519                e
1520            ))),
1521        }
1522    }
1523
1524    /// Audits a PSBT under the warn-only Ordinal Shield policy.
1525    /// Returns `Ok(())` when analysis succeeds, or an `Error` for malformed/unanalyzable payloads.
1526    #[wasm_bindgen(js_name = auditPsbt)]
1527    pub fn audit_psbt(&self, psbt_base64: &str, options: JsValue) -> Result<(), JsValue> {
1528        self.check_vitality()?;
1529
1530        let sign_opts: Option<crate::builder::SignOptions> =
1531            if options.is_null() || options.is_undefined() {
1532                None
1533            } else {
1534                match serde_wasm_bindgen::from_value(options) {
1535                    Ok(opts) => Some(opts),
1536                    Err(e) => return Err(JsValue::from_str(&format!("Invalid options: {}", e))),
1537                }
1538            };
1539
1540        use base64::Engine;
1541        let psbt_bytes = base64::engine::general_purpose::STANDARD
1542            .decode(psbt_base64)
1543            .map_err(|e| JsValue::from_str(&format!("Invalid base64: {e}")))?;
1544
1545        let psbt = bitcoin::psbt::Psbt::deserialize(&psbt_bytes)
1546            .map_err(|e| JsValue::from_str(&format!("Invalid PSBT: {e}")))?;
1547
1548        let inner = self
1549            .inner
1550            .try_borrow()
1551            .map_err(|e| JsValue::from_str(&format!("Wallet busy (audit_psbt): {}", e)))?;
1552
1553        // 1. Build known_inscriptions map
1554        let mut known_inscriptions: std::collections::HashMap<
1555            (bitcoin::Txid, u32),
1556            Vec<(String, u64)>,
1557        > = std::collections::HashMap::new();
1558        for ins in &inner.inscriptions {
1559            known_inscriptions
1560                .entry((ins.satpoint.outpoint.txid, ins.satpoint.outpoint.vout))
1561                .or_default()
1562                .push((ins.id.clone(), ins.satpoint.offset));
1563        }
1564
1565        // 2. Perform Audit
1566        let allowed_inputs = sign_opts.as_ref().and_then(|o| o.sign_inputs.as_deref());
1567
1568        crate::ordinals::shield::audit_psbt(
1569            &psbt,
1570            &known_inscriptions,
1571            allowed_inputs,
1572            inner.vault_wallet.network(),
1573        )
1574        .map_err(|e| JsValue::from_str(&e.to_string()))
1575    }
1576
1577    /// Sign a message using the private key corresponding to the address.
1578    /// Returns signature string (base64).
1579    pub fn sign_message(&self, address: &str, message: &str) -> Result<String, JsValue> {
1580        self.check_vitality()?;
1581        match self.inner.try_borrow() {
1582            Ok(inner) => inner
1583                .sign_message(address, message)
1584                .map_err(|e| JsValue::from_str(&e)),
1585            Err(e) => Err(JsValue::from_str(&format!(
1586                "Wallet busy (sign_message): {}",
1587                e
1588            ))),
1589        }
1590    }
1591
1592    /// Broadcast a signed PSBT to the network.
1593    /// Returns the transaction ID (txid) as a hex string.
1594    #[cfg(target_arch = "wasm32")]
1595    #[wasm_bindgen(js_name = broadcast)]
1596    pub fn broadcast(
1597        &self,
1598        signed_psbt_base64: String,
1599        esplora_url: String,
1600    ) -> Result<js_sys::Promise, JsValue> {
1601        self.check_vitality()?;
1602        use crate::builder::SyncSleeper;
1603
1604        Ok(wasm_bindgen_futures::future_to_promise(async move {
1605            // Decode PSBT - no borrow needed
1606            use base64::Engine;
1607            let psbt_bytes = base64::engine::general_purpose::STANDARD
1608                .decode(&signed_psbt_base64)
1609                .map_err(|e| JsValue::from_str(&format!("Invalid base64: {e}")))?;
1610
1611            let psbt = bitcoin::psbt::Psbt::deserialize(&psbt_bytes)
1612                .map_err(|e| JsValue::from_str(&format!("Invalid PSBT: {e}")))?;
1613
1614            // Extract the finalized transaction
1615            let tx = psbt
1616                .extract_tx()
1617                .map_err(|e| JsValue::from_str(&format!("Failed to extract tx: {e}")))?;
1618
1619            // Broadcast via Esplora (no RefCell borrow needed)
1620            let client = esplora_client::Builder::new(&esplora_url)
1621                .build_async_with_sleeper::<SyncSleeper>()
1622                .map_err(|e| JsValue::from_str(&format!("Failed to create client: {e:?}")))?;
1623
1624            client
1625                .broadcast(&tx)
1626                .await
1627                .map_err(|e| JsValue::from_str(&format!("Broadcast failed: {e}")))?;
1628
1629            Ok(JsValue::from(tx.compute_txid().to_string()))
1630        }))
1631    }
1632}
1633
1634// Integration tests under src/tests/.
1635#[cfg(test)]
1636pub mod tests;